Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3cfe8c70c | |||
| 95e14e6340 | |||
| ff100b2472 | |||
| a4f0c00cac | |||
| 25f56b3c71 | |||
| e4e34b092c | |||
| 82fd7d35a5 | |||
| f417faab9a |
@@ -23,6 +23,16 @@ go test ./...
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
M7 (reflection): when a session ends, a fourth AI role — the reviewer —
|
||||||
|
reflects on it, read against your recent sessions, and produces two short
|
||||||
|
lines: a recap shown on the Review screen, and a carry-forward takeaway that
|
||||||
|
grounds the coach the next time you plan. It runs once asynchronously on
|
||||||
|
entering Review, never blocks the End button, and degrades gracefully — with
|
||||||
|
no backend (or a slow/failed call) Review and Planning behave exactly as
|
||||||
|
before. The carry-forward is snapshot-persisted (latest-wins) and composes
|
||||||
|
into the coach's grounding; the reflection lines are short and cross the wire
|
||||||
|
by design, while the knowledge profile still does not.
|
||||||
|
|
||||||
M6 (knowledge port): the planning coach now sharpens intents against who you
|
M6 (knowledge port): the planning coach now sharpens intents against who you
|
||||||
actually are. A single profile file (`~/.antidrift/knowledge.md`, overridable
|
actually are. A single profile file (`~/.antidrift/knowledge.md`, overridable
|
||||||
via `ANTIDRIFT_KNOWLEDGE_FILE`) holds your standing context — priorities,
|
via `ANTIDRIFT_KNOWLEDGE_FILE`) holds your standing context — priorities,
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ func main() {
|
|||||||
ctrl.SetCoach(svc)
|
ctrl.SetCoach(svc)
|
||||||
ctrl.SetDriftJudge(svc)
|
ctrl.SetDriftJudge(svc)
|
||||||
ctrl.SetNudge(svc)
|
ctrl.SetNudge(svc)
|
||||||
log.Printf("ai: %s backend (coach + drift judge + nudge)", backend.Name())
|
ctrl.SetReviewer(svc)
|
||||||
|
log.Printf("ai: %s backend (coach + drift judge + nudge + reviewer)", backend.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire the Tasks port: Amazing Marvin via ampy's `am` CLI. The command is
|
// Wire the Tasks port: Amazing Marvin via ampy's `am` CLI. The command is
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
|||||||
|
# M7 — Reflection: Design
|
||||||
|
|
||||||
|
**Status:** approved
|
||||||
|
**Date:** 2026-06-01
|
||||||
|
**Milestone:** M7 — Reflection (the deferred AI **reviewer** role, promoted into
|
||||||
|
the main loop)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Close the loop. Through M6 the system has three live AI roles — Coach,
|
||||||
|
DriftJudge, Nudge — but nothing looks *back*. M7 adds the fourth role, the
|
||||||
|
**Reviewer**: when a session ends, it reflects on what just happened, read
|
||||||
|
against your recent sessions, and produces two short lines —
|
||||||
|
|
||||||
|
- a **recap**, shown on the Review screen (how the session went), and
|
||||||
|
- a **carry-forward**, which grounds the coach the next time you plan (what to
|
||||||
|
do differently).
|
||||||
|
|
||||||
|
This is the "Focus OS Reflection" step from the roadmap. It makes the loop
|
||||||
|
self-reinforcing: each session's takeaway sharpens the next session's plan.
|
||||||
|
|
||||||
|
The whole feature is one cheap async call per session, never blocking, and
|
||||||
|
degrades gracefully — if the AI backend is off or slow, Review/End/Planning
|
||||||
|
behave exactly as they do today.
|
||||||
|
|
||||||
|
## Design constraints
|
||||||
|
|
||||||
|
Two non-negotiables shaped every decision below:
|
||||||
|
|
||||||
|
- **Efficient.** One LLM call per session, fired once on entering Review. The
|
||||||
|
"recent sessions" context is a local file read, not an LLM cost. The prompt
|
||||||
|
carries the finished session plus a few *compact* prior summaries (outcome +
|
||||||
|
top buckets, never raw event logs), so it stays small. The result is computed
|
||||||
|
once and persisted; the next planning cycle reads it from disk and does **not**
|
||||||
|
re-run the reviewer.
|
||||||
|
- **Low friction.** It runs automatically (no "request reflection" button). It is
|
||||||
|
**never blocking** — the **End** button works immediately whether or not the
|
||||||
|
reviewer has returned. The carry-forward is **auto-applied** as coach grounding
|
||||||
|
next time; there is no approve/dismiss step.
|
||||||
|
|
||||||
|
## The new AI role (`ai` package)
|
||||||
|
|
||||||
|
A leaf role that mirrors Coach/DriftJudge/Nudge: it takes only primitives and
|
||||||
|
imports neither `store` nor `session`. The controller is responsible for turning
|
||||||
|
session data into the strings this role consumes.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Reflection is the reviewer's output: two short, single-line fields.
|
||||||
|
type Reflection struct {
|
||||||
|
Recap string // backward-looking, ≤1 short line — shown on Review
|
||||||
|
CarryForward string // forward-looking, ≤1 short line — grounds the next
|
||||||
|
// coach and is shown on the next Planning screen
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review reflects on a just-finished session, read against recent history.
|
||||||
|
// finished: a compact description of the session that just ended.
|
||||||
|
// history: a compact description of the last few prior sessions ("" if none).
|
||||||
|
func (b *Backend) Review(ctx context.Context, finished, history string) (Reflection, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
- A new prompt builder composes a Reviewer prompt from `finished` and `history`,
|
||||||
|
instructing the model to return **at most one short line per field** so output
|
||||||
|
stays bounded.
|
||||||
|
- A parser extracts the two lines from the backend output, following the
|
||||||
|
existing role-parsing pattern in the `ai` package.
|
||||||
|
- **Graceful fallback:** any error, empty output, or unparseable result yields a
|
||||||
|
zero `Reflection{}` (both fields ""), which the rest of the system treats as
|
||||||
|
"no reflection available." `Review` never panics and never blocks.
|
||||||
|
|
||||||
|
The prompt is built so that an empty `history` (the first-ever session) still
|
||||||
|
produces a sensible recap from `finished` alone.
|
||||||
|
|
||||||
|
## Orchestration (`session.Controller`)
|
||||||
|
|
||||||
|
The controller owns all orchestration, reusing the established async +
|
||||||
|
generation-counter + graceful-degradation pattern already used for the coach,
|
||||||
|
tasks, and knowledge fetches.
|
||||||
|
|
||||||
|
### Fetch on entering Review
|
||||||
|
|
||||||
|
`enterReview` (reached from both `Complete` → `completed` and `Expire` →
|
||||||
|
`expired`) fires `startReflectionFetchLocked()`:
|
||||||
|
|
||||||
|
1. Increment a `reflectionGen` counter and capture it for this fetch.
|
||||||
|
2. Build `finished` from the **in-memory** frozen stats of the session that just
|
||||||
|
ended: next action, success condition, outcome, switch count, and the top app
|
||||||
|
time buckets.
|
||||||
|
3. Build `history` by reading the **last 5 prior** `SessionSummary` records from
|
||||||
|
`audit.jsonl`. The just-finished session is **not** in the chain yet — it is
|
||||||
|
appended only at `End` — so there is no double-counting.
|
||||||
|
4. In a goroutine, call `reviewer.Review(ctx, finished, history)` under a
|
||||||
|
timeout. On return, re-acquire the lock; if `reflectionGen` still matches the
|
||||||
|
captured value, cache `reflectionRecap` and set `carryForward`
|
||||||
|
(**latest-wins**); otherwise discard the result as stale. Then notify.
|
||||||
|
|
||||||
|
The generation guard ensures a slow review from a superseded session can never
|
||||||
|
overwrite a newer one: the most recent *completed* review wins.
|
||||||
|
|
||||||
|
### Grounding the next coach — no interface change
|
||||||
|
|
||||||
|
`grounding` is already a free-form string parameter on `ai.Coach` (added in M6).
|
||||||
|
M7 needs **no** change to the `ai.Coach` signature: in `RequestCoach` the
|
||||||
|
controller composes the existing knowledge profile text **and** the current
|
||||||
|
`carryForward` into that one `grounding` string (profile block, then a short
|
||||||
|
"Last session:" line). M7 is fully additive to the AI interface.
|
||||||
|
|
||||||
|
`carryForward` is latest-wins and survives `End` (it is not cleared with the
|
||||||
|
commitment/stats), so it is present when the next Planning begins.
|
||||||
|
|
||||||
|
### State projection
|
||||||
|
|
||||||
|
The State view gains a small reflection projection so the browser can render it:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ReflectionView struct {
|
||||||
|
Status string // "idle" | "pending" | "ready" | "absent"
|
||||||
|
Recap string // shown on Review
|
||||||
|
CarryForward string // shown on Planning
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- On **Review**, the view carries `Status` + `Recap`.
|
||||||
|
- On **Planning**, the view carries the `CarryForward` line.
|
||||||
|
|
||||||
|
Unlike the M6 *profile* (large and private, deliberately kept off the wire), the
|
||||||
|
reflection lines are short and **exist to be displayed**, so they are
|
||||||
|
intentionally included in the State payload sent to the browser.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
Snapshot-only, latest-wins. The persisted snapshot JSON gains `reflectionRecap`,
|
||||||
|
`carryForward`, and a small `reflectionStatus` enum (idle/pending/ready/absent).
|
||||||
|
There are **no** changes to `audit.jsonl`, no new files, and no new on-disk
|
||||||
|
format. The permanent, hash-chained `SessionSummary` is untouched.
|
||||||
|
|
||||||
|
One small additive reader is needed on the store:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// RecentSessions returns up to n most-recent summaries from the audit chain,
|
||||||
|
// most-recent first (or oldest-first — fixed by the plan), [] if the log is
|
||||||
|
// absent or empty.
|
||||||
|
func RecentSessions(path string, n int) ([]SessionSummary, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Today's `readSummaries` is unexported; `RecentSessions` exposes a bounded slice
|
||||||
|
of it for the controller to format into `history`.
|
||||||
|
|
||||||
|
## UI (`web` static assets)
|
||||||
|
|
||||||
|
- **Review screen:** the `Recap` rendered as a subtle line (nudge-band style),
|
||||||
|
with `pending` and `absent` states. The **End** button works immediately
|
||||||
|
regardless of reflection status.
|
||||||
|
- **Planning screen:** the `CarryForward` rendered as a quiet one-liner
|
||||||
|
("Last time: …"), mirroring the M6 knowledge indicator. No buttons, no added
|
||||||
|
clicks anywhere.
|
||||||
|
|
||||||
|
## Daemon wiring (`cmd/antidriftd/main.go`)
|
||||||
|
|
||||||
|
The reviewer backend is wired into the controller (`ctrl.SetReviewer(...)`),
|
||||||
|
gated on AI-backend availability exactly like the other roles. With no backend
|
||||||
|
configured, the reviewer is simply absent and reflection silently does nothing.
|
||||||
|
|
||||||
|
## Error handling / graceful degradation
|
||||||
|
|
||||||
|
- Backend off, error, empty output, unparseable result, or no prior history →
|
||||||
|
nothing is shown; Review, End, and Planning behave exactly as today.
|
||||||
|
- The reviewer **never blocks a transition**. `End` does not wait for it.
|
||||||
|
- The generation counter discards late results from a superseded review.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **`ai`:** the Reviewer prompt includes both `finished` and `history`; the
|
||||||
|
parser extracts two lines; error/blank/unparseable input yields an empty
|
||||||
|
`Reflection`.
|
||||||
|
- **`session`:** reflection is fetched on `enterReview`; the result is cached and
|
||||||
|
rides the snapshot; a stale (superseded-generation) result is discarded; the
|
||||||
|
`carryForward` composes into the next coach's `grounding`; everything degrades
|
||||||
|
gracefully when no reviewer is set; `RecentSessions` returns the last *n*
|
||||||
|
summaries in the expected order.
|
||||||
|
- **`web`:** the Review payload carries the `Recap`; the Planning payload carries
|
||||||
|
the `CarryForward`; reflection text is intentionally present on the wire.
|
||||||
|
|
||||||
|
## Out of scope (this milestone)
|
||||||
|
|
||||||
|
- **A durable reflection history** (`reflections.jsonl`). Nothing in the loop
|
||||||
|
needs it — the next coach only needs the latest carry-forward — and the
|
||||||
|
permanent `SessionSummary` still records outcome/buckets/switches for every
|
||||||
|
session. Promoting to a durable log later would be an additive change.
|
||||||
|
- **Changing the `ai.Coach` signature.** Grounding is already a free-form string.
|
||||||
|
- **Refactoring `session.go`.** It is ~1054 lines and M7 adds another per-role
|
||||||
|
async-fetch block; the repetition across the coach/tasks/knowledge/reviewer
|
||||||
|
fetchers is a fair future consolidation target, but extracting it now would
|
||||||
|
destabilize four working roles for no functional gain. M7 follows the
|
||||||
|
established per-role pattern for consistency and reviewability.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reflection is the reviewer's output: two short, single-line fields.
|
||||||
|
type Reflection struct {
|
||||||
|
Recap string // backward-looking: how the session went (shown on Review)
|
||||||
|
CarryForward string // forward-looking: one takeaway for the next session
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reviewer reflects on a just-finished session, read against recent history. It
|
||||||
|
// takes primitives, not domain/store types, so ai stays a leaf package.
|
||||||
|
//
|
||||||
|
// finished: a compact description of the session that just ended.
|
||||||
|
// history: a compact description of the last few prior sessions ("" if none).
|
||||||
|
type Reviewer interface {
|
||||||
|
Review(ctx context.Context, finished, history string) (Reflection, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidReflection marks output that parsed as JSON but lacked a usable
|
||||||
|
// recap, so callers can distinguish "no attempt" from "bad attempt".
|
||||||
|
var ErrInvalidReflection = errors.New("ai: invalid reflection")
|
||||||
|
|
||||||
|
// Review makes Service satisfy Reviewer over the same backend as Coach.
|
||||||
|
func (s *Service) Review(ctx context.Context, finished, history string) (Reflection, error) {
|
||||||
|
out, err := s.backend.Run(ctx, buildReviewPrompt(finished, history))
|
||||||
|
if err != nil {
|
||||||
|
return Reflection{}, err
|
||||||
|
}
|
||||||
|
return parseReflection(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildReviewPrompt(finished, history string) string {
|
||||||
|
preamble := `You are a focus reviewer. A work session just ended. Reflect on it in two short lines.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||||
|
{"recap": "<one sentence: how this session went>", "carry_forward": "<one sentence: the single most useful thing to do differently next session>"}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- recap: one short sentence, backward-looking, grounded in the session below.
|
||||||
|
- carry_forward: one short, actionable sentence for the next session. It may reference patterns across the recent sessions if any are given.
|
||||||
|
- Keep each field to a single short sentence.`
|
||||||
|
|
||||||
|
hist := ""
|
||||||
|
if strings.TrimSpace(history) != "" {
|
||||||
|
hist = "\n\n## Recent sessions (oldest first)\n" + history
|
||||||
|
}
|
||||||
|
return preamble + "\n\n## Session that just ended\n" + finished + hist
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawReflection struct {
|
||||||
|
Recap string `json:"recap"`
|
||||||
|
CarryForward string `json:"carry_forward"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseReflection extracts a Reflection from raw CLI output. A blank recap is
|
||||||
|
// rejected (ErrInvalidReflection); an empty carry_forward is allowed.
|
||||||
|
func parseReflection(s string) (Reflection, error) {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return Reflection{}, ErrEmptyResponse
|
||||||
|
}
|
||||||
|
if strings.IndexByte(s, '{') < 0 {
|
||||||
|
return Reflection{}, ErrNoJSON
|
||||||
|
}
|
||||||
|
jsonStr, err := extractJSON(s)
|
||||||
|
if err != nil {
|
||||||
|
return Reflection{}, fmt.Errorf("%w: %v", ErrInvalidReflection, err)
|
||||||
|
}
|
||||||
|
var raw rawReflection
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||||||
|
return Reflection{}, fmt.Errorf("%w: %v", ErrInvalidReflection, err)
|
||||||
|
}
|
||||||
|
recap := strings.TrimSpace(raw.Recap)
|
||||||
|
if recap == "" {
|
||||||
|
return Reflection{}, ErrInvalidReflection
|
||||||
|
}
|
||||||
|
return Reflection{Recap: recap, CarryForward: strings.TrimSpace(raw.CarryForward)}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReviewPromptIncludesSessionAndHistory(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `{"recap":"r","carry_forward":"c"}`}
|
||||||
|
if _, err := NewService(fb).Review(context.Background(), "FINISHED-BLOCK", "HISTORY-BLOCK"); err != nil {
|
||||||
|
t.Fatalf("review: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "FINISHED-BLOCK") {
|
||||||
|
t.Fatalf("prompt missing finished session: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "HISTORY-BLOCK") || !strings.Contains(fb.gotPrompt, "Recent sessions") {
|
||||||
|
t.Fatalf("prompt missing history block: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewPromptOmitsEmptyHistory(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `{"recap":"r","carry_forward":"c"}`}
|
||||||
|
if _, err := NewService(fb).Review(context.Background(), "FINISHED-BLOCK", ""); err != nil {
|
||||||
|
t.Fatalf("review: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(fb.gotPrompt, "Recent sessions") {
|
||||||
|
t.Fatalf("empty history must not add a history header: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewServiceParsesReflection(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `sure: {"recap":"held focus on the port","carry_forward":"start in the editor next time"}`}
|
||||||
|
refl, err := NewService(fb).Review(context.Background(), "f", "h")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("review: %v", err)
|
||||||
|
}
|
||||||
|
if refl.Recap != "held focus on the port" || refl.CarryForward != "start in the editor next time" {
|
||||||
|
t.Fatalf("bad reflection: %+v", refl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewServiceBackendError(t *testing.T) {
|
||||||
|
fb := &fakeBackend{err: errors.New("boom")}
|
||||||
|
if _, err := NewService(fb).Review(context.Background(), "f", "h"); err == nil {
|
||||||
|
t.Fatal("want backend error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseReflectionEmpty(t *testing.T) {
|
||||||
|
if _, err := parseReflection(""); !errors.Is(err, ErrEmptyResponse) {
|
||||||
|
t.Fatalf("want ErrEmptyResponse, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseReflectionNoJSON(t *testing.T) {
|
||||||
|
if _, err := parseReflection("I cannot help."); !errors.Is(err, ErrNoJSON) {
|
||||||
|
t.Fatalf("want ErrNoJSON, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseReflectionRequiresRecap(t *testing.T) {
|
||||||
|
if _, err := parseReflection(`{"recap":" ","carry_forward":"x"}`); !errors.Is(err, ErrInvalidReflection) {
|
||||||
|
t.Fatalf("blank recap should be invalid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseReflectionAllowsEmptyCarryForward(t *testing.T) {
|
||||||
|
refl, err := parseReflection(`{"recap":"did the thing","carry_forward":""}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("carry_forward may be empty: %v", err)
|
||||||
|
}
|
||||||
|
if refl.Recap != "did the thing" || refl.CarryForward != "" {
|
||||||
|
t.Fatalf("bad reflection: %+v", refl)
|
||||||
|
}
|
||||||
|
}
|
||||||
+172
-1
@@ -7,6 +7,7 @@ package session
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -58,6 +59,21 @@ const (
|
|||||||
knowledgeError = "error"
|
knowledgeError = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const reflectionTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
const reflectionHistoryN = 5
|
||||||
|
|
||||||
|
// reflectionTopBuckets caps how many per-window time buckets the finished-session
|
||||||
|
// block lists for the reviewer, keeping the prompt compact.
|
||||||
|
const reflectionTopBuckets = 3
|
||||||
|
|
||||||
|
const (
|
||||||
|
reflectionIdle = "idle"
|
||||||
|
reflectionPending = "pending"
|
||||||
|
reflectionReady = "ready"
|
||||||
|
reflectionAbsent = "absent"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
driftDebounce = 10 * time.Second
|
driftDebounce = 10 * time.Second
|
||||||
driftTimeout = 30 * time.Second
|
driftTimeout = 30 * time.Second
|
||||||
@@ -125,6 +141,12 @@ type Controller struct {
|
|||||||
knowledgeChars int
|
knowledgeChars int
|
||||||
knowledgeGen int
|
knowledgeGen int
|
||||||
|
|
||||||
|
reviewer ai.Reviewer
|
||||||
|
reflectionStatus string
|
||||||
|
reflectionRecap string
|
||||||
|
carryForward string // latest-wins takeaway; grounds the next coach
|
||||||
|
reflectionGen int
|
||||||
|
|
||||||
allowedClasses []string // durable: the active session's allowed window classes
|
allowedClasses []string // durable: the active session's allowed window classes
|
||||||
judge ai.DriftJudge
|
judge ai.DriftJudge
|
||||||
driftStatus string
|
driftStatus string
|
||||||
@@ -194,6 +216,15 @@ type KnowledgeView struct {
|
|||||||
Chars int `json:"chars,omitempty"`
|
Chars int `json:"chars,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReflectionView projects the reviewer's output. Recap is shown on Review;
|
||||||
|
// CarryForward is shown on the next Planning screen. Unlike the knowledge
|
||||||
|
// profile, these short lines exist to be displayed, so they cross the wire.
|
||||||
|
type ReflectionView struct {
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Recap string `json:"recap,omitempty"`
|
||||||
|
CarryForward string `json:"carry_forward,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// WindowView / BucketView / EvidenceView are the evidence projection.
|
// WindowView / BucketView / EvidenceView are the evidence projection.
|
||||||
type WindowView struct {
|
type WindowView struct {
|
||||||
Class string `json:"class"`
|
Class string `json:"class"`
|
||||||
@@ -222,6 +253,7 @@ type State struct {
|
|||||||
Coach *CoachView `json:"coach,omitempty"`
|
Coach *CoachView `json:"coach,omitempty"`
|
||||||
Tasks *TasksView `json:"tasks,omitempty"`
|
Tasks *TasksView `json:"tasks,omitempty"`
|
||||||
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
|
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
|
||||||
|
Reflection *ReflectionView `json:"reflection,omitempty"`
|
||||||
Drift *DriftView `json:"drift,omitempty"`
|
Drift *DriftView `json:"drift,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +273,9 @@ func New(snapshotPath string) (*Controller, error) {
|
|||||||
sessionsDir: filepath.Join(dir, "sessions"),
|
sessionsDir: filepath.Join(dir, "sessions"),
|
||||||
clock: time.Now,
|
clock: time.Now,
|
||||||
outcomePending: s.OutcomePending,
|
outcomePending: s.OutcomePending,
|
||||||
|
reflectionStatus: s.ReflectionStatus,
|
||||||
|
reflectionRecap: s.ReflectionRecap,
|
||||||
|
carryForward: s.CarryForward,
|
||||||
}
|
}
|
||||||
if s.DeadlineUnixSecs > 0 {
|
if s.DeadlineUnixSecs > 0 {
|
||||||
c.deadline = time.Unix(s.DeadlineUnixSecs, 0)
|
c.deadline = time.Unix(s.DeadlineUnixSecs, 0)
|
||||||
@@ -354,6 +389,16 @@ func (c *Controller) stateLocked() State {
|
|||||||
}
|
}
|
||||||
st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars}
|
st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars}
|
||||||
}
|
}
|
||||||
|
if c.carryForward != "" {
|
||||||
|
st.Reflection = &ReflectionView{CarryForward: c.carryForward}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.runtimeState == domain.RuntimeReview {
|
||||||
|
rstatus := c.reflectionStatus
|
||||||
|
if rstatus == "" {
|
||||||
|
rstatus = reflectionIdle
|
||||||
|
}
|
||||||
|
st.Reflection = &ReflectionView{Status: rstatus, Recap: c.reflectionRecap}
|
||||||
}
|
}
|
||||||
if c.runtimeState == domain.RuntimeActive {
|
if c.runtimeState == domain.RuntimeActive {
|
||||||
status := c.driftStatus
|
status := c.driftStatus
|
||||||
@@ -387,6 +432,9 @@ func (c *Controller) persistLocked() error {
|
|||||||
snap.SessionID = c.stats.SessionID
|
snap.SessionID = c.stats.SessionID
|
||||||
}
|
}
|
||||||
snap.AllowedWindowClasses = c.allowedClasses
|
snap.AllowedWindowClasses = c.allowedClasses
|
||||||
|
snap.ReflectionStatus = c.reflectionStatus
|
||||||
|
snap.ReflectionRecap = c.reflectionRecap
|
||||||
|
snap.CarryForward = c.carryForward
|
||||||
return store.Save(c.snapshotPath, snap)
|
return store.Save(c.snapshotPath, snap)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,6 +586,114 @@ func (c *Controller) startKnowledgeFetchLocked() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and
|
||||||
|
// leaves the coach ungrounded by any carry-forward.
|
||||||
|
func (c *Controller) SetReviewer(r ai.Reviewer) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.reviewer = r
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// startReflectionFetchLocked kicks off an asynchronous reflection when a
|
||||||
|
// reviewer is set, on entering Review. Unlike the tasks/knowledge fetches, the
|
||||||
|
// completion guard is generation-only (not state-gated): the carry-forward must
|
||||||
|
// still apply if the user clicks End before the reviewer returns. A superseded
|
||||||
|
// review (a later session's fetch) bumps the generation and discards this one.
|
||||||
|
// The recap and carry-forward are cleared up front so a failed/slow reviewer
|
||||||
|
// never leaves stale data from the previous session. Caller holds mu.
|
||||||
|
func (c *Controller) startReflectionFetchLocked() {
|
||||||
|
c.reflectionRecap = ""
|
||||||
|
c.carryForward = ""
|
||||||
|
if c.reviewer == nil {
|
||||||
|
c.reflectionStatus = reflectionIdle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.reflectionGen++
|
||||||
|
gen := c.reflectionGen
|
||||||
|
c.reflectionStatus = reflectionPending
|
||||||
|
reviewer := c.reviewer
|
||||||
|
finished := c.buildReflectionFinishedLocked()
|
||||||
|
// Read the history synchronously, here under the lock, on purpose: it must
|
||||||
|
// happen-before End appends the just-finished session to the audit chain, so
|
||||||
|
// that session is excluded from "recent history" and not double-counted (it
|
||||||
|
// is already carried in `finished`). Moving this into the goroutine would
|
||||||
|
// race with End's append and reintroduce that double-count. The read is
|
||||||
|
// bounded to reflectionHistoryN summaries and runs once per Review entry, not
|
||||||
|
// on any hot path.
|
||||||
|
history := buildReflectionHistory(c.auditPath)
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), reflectionTimeout)
|
||||||
|
defer cancel()
|
||||||
|
refl, err := reviewer.Review(ctx, finished, history)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if gen != c.reflectionGen {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return // superseded review: discard
|
||||||
|
}
|
||||||
|
if err != nil || strings.TrimSpace(refl.Recap) == "" {
|
||||||
|
c.reflectionStatus = reflectionAbsent
|
||||||
|
c.reflectionRecap = ""
|
||||||
|
c.carryForward = ""
|
||||||
|
} else {
|
||||||
|
c.reflectionStatus = reflectionReady
|
||||||
|
c.reflectionRecap = refl.Recap
|
||||||
|
c.carryForward = refl.CarryForward
|
||||||
|
}
|
||||||
|
_ = c.persistLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.notify()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildReflectionFinishedLocked renders the just-finished session as a compact
|
||||||
|
// block for the reviewer. Caller holds mu; c.stats/c.commitment are still set
|
||||||
|
// (End clears them, but enterReview runs before End). Reuses bucketViews for the
|
||||||
|
// per-window totals, already sorted desc by seconds.
|
||||||
|
func (c *Controller) buildReflectionFinishedLocked() string {
|
||||||
|
var na, sc string
|
||||||
|
if c.commitment != nil {
|
||||||
|
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
|
||||||
|
}
|
||||||
|
outcome := c.outcomePending
|
||||||
|
if outcome == "" {
|
||||||
|
outcome = "completed"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "Next action: %s\n", na)
|
||||||
|
fmt.Fprintf(&b, "Success condition: %s\n", sc)
|
||||||
|
fmt.Fprintf(&b, "Outcome: %s\n", outcome)
|
||||||
|
if c.stats != nil {
|
||||||
|
fmt.Fprintf(&b, "Context switches: %d\n", c.stats.SwitchCount)
|
||||||
|
for i, bv := range bucketViews(c.stats.Buckets) {
|
||||||
|
if i >= reflectionTopBuckets {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "- %s · %s: %dm\n", bv.Class, bv.Title, bv.Seconds/60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildReflectionHistory renders the last few prior sessions as compact lines.
|
||||||
|
// The just-finished session is not yet in the chain (End appends it), so it is
|
||||||
|
// not double-counted. Returns "" when there is no usable history.
|
||||||
|
func buildReflectionHistory(auditPath string) string {
|
||||||
|
sums, err := store.RecentSessions(auditPath, reflectionHistoryN)
|
||||||
|
if err != nil || len(sums) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, s := range sums {
|
||||||
|
top := ""
|
||||||
|
if len(s.Buckets) > 0 {
|
||||||
|
top = fmt.Sprintf(", top %s %dm", s.Buckets[0].Class, s.Buckets[0].Seconds/60)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "- %s: %s (%d switches%s)\n", s.Outcome, s.NextAction, s.SwitchCount, top)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
// AllowedClassesForTest exposes the session allowed classes for tests.
|
// AllowedClassesForTest exposes the session allowed classes for tests.
|
||||||
func (c *Controller) AllowedClassesForTest() []string {
|
func (c *Controller) AllowedClassesForTest() []string {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -631,6 +787,20 @@ func (c *Controller) Refocus() error {
|
|||||||
return c.persistLocked()
|
return c.persistLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// composedGroundingLocked combines the standing profile (knowledge port) with
|
||||||
|
// the latest carry-forward takeaway into the single free-form grounding string
|
||||||
|
// the coach already accepts. Caller holds mu.
|
||||||
|
func (c *Controller) composedGroundingLocked() string {
|
||||||
|
g := c.knowledgeText
|
||||||
|
if c.carryForward != "" {
|
||||||
|
if g != "" {
|
||||||
|
g += "\n\n"
|
||||||
|
}
|
||||||
|
g += "Last session's takeaway: " + c.carryForward
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
|
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
|
||||||
// not in planning; otherwise never a hard error (failures surface as coach
|
// not in planning; otherwise never a hard error (failures surface as coach
|
||||||
// state). The proposal is ephemeral and never persisted.
|
// state). The proposal is ephemeral and never persisted.
|
||||||
@@ -654,7 +824,7 @@ func (c *Controller) RequestCoach(intent string) error {
|
|||||||
c.coachErr = ""
|
c.coachErr = ""
|
||||||
c.coachProposal = nil
|
c.coachProposal = nil
|
||||||
coach := c.coach
|
coach := c.coach
|
||||||
grounding := c.knowledgeText
|
grounding := c.composedGroundingLocked()
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
c.notify()
|
c.notify()
|
||||||
|
|
||||||
@@ -760,6 +930,7 @@ func (c *Controller) enterReview(outcome string) error {
|
|||||||
}
|
}
|
||||||
c.runtimeState = next
|
c.runtimeState = next
|
||||||
c.outcomePending = outcome
|
c.outcomePending = outcome
|
||||||
|
c.startReflectionFetchLocked()
|
||||||
return c.persistLocked()
|
return c.persistLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -1029,3 +1030,154 @@ func TestCoachReceivesCachedGrounding(t *testing.T) {
|
|||||||
t.Fatalf("coach grounding = %q, want the cached profile", fc.grounding())
|
t.Fatalf("coach grounding = %q, want the cached profile", fc.grounding())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeReviewer struct {
|
||||||
|
refl ai.Reflection
|
||||||
|
err error
|
||||||
|
gate chan struct{} // if non-nil, Review blocks until it receives
|
||||||
|
calls int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReviewer) Review(ctx context.Context, finished, history string) (ai.Reflection, error) {
|
||||||
|
atomic.AddInt32(&f.calls, 1)
|
||||||
|
if f.gate != nil {
|
||||||
|
<-f.gate
|
||||||
|
}
|
||||||
|
return f.refl, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitReflectionStatus polls until the reflection view reaches want, or fails after 2s.
|
||||||
|
func waitReflectionStatus(t *testing.T, c *Controller, want string) State {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
st := c.State()
|
||||||
|
if st.Reflection != nil && st.Reflection.Status == want {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("reflection status never reached %q (last: %+v)", want, c.State().Reflection)
|
||||||
|
return State{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func driveToReview(t *testing.T, c *Controller) {
|
||||||
|
t.Helper()
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("planning: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Complete(); err != nil {
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReflectionFetchedOnReview(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
rev := &fakeReviewer{refl: ai.Reflection{Recap: "held focus", CarryForward: "start in the editor"}}
|
||||||
|
c.SetReviewer(rev)
|
||||||
|
driveToReview(t, c)
|
||||||
|
st := waitReflectionStatus(t, c, "ready")
|
||||||
|
if st.Reflection.Recap != "held focus" {
|
||||||
|
t.Fatalf("recap not projected on Review: %+v", st.Reflection)
|
||||||
|
}
|
||||||
|
if n := atomic.LoadInt32(&rev.calls); n != 1 {
|
||||||
|
t.Fatalf("reviewer should be called exactly once per session, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoReviewerYieldsIdleReflection(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
driveToReview(t, c)
|
||||||
|
st := c.State()
|
||||||
|
if st.Reflection == nil || st.Reflection.Status != "idle" {
|
||||||
|
t.Fatalf("nil reviewer should yield idle reflection, got %+v", st.Reflection)
|
||||||
|
}
|
||||||
|
if err := c.End(); err != nil {
|
||||||
|
t.Fatalf("End must still work with no reviewer: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCarryForwardGroundsNextCoach(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetReviewer(&fakeReviewer{refl: ai.Reflection{Recap: "ok", CarryForward: "begin with the hardest test"}})
|
||||||
|
fc := &fakeCoach{prop: ai.Proposal{NextAction: "a", SuccessCondition: "b", TimeboxSecs: 1200}}
|
||||||
|
c.SetCoach(fc)
|
||||||
|
driveToReview(t, c)
|
||||||
|
waitReflectionStatus(t, c, "ready")
|
||||||
|
if err := c.End(); err != nil {
|
||||||
|
t.Fatalf("end: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("planning: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.RequestCoach("ship it"); err != nil {
|
||||||
|
t.Fatalf("request coach: %v", err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && fc.grounding() == "" {
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if g := fc.grounding(); !strings.Contains(g, "begin with the hardest test") {
|
||||||
|
t.Fatalf("carry-forward not threaded into coach grounding: %q", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReflectionStaleResultDiscarded(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
slow := &fakeReviewer{refl: ai.Reflection{Recap: "OLD", CarryForward: "old"}, gate: make(chan struct{})}
|
||||||
|
c.SetReviewer(slow)
|
||||||
|
driveToReview(t, c) // gen1 pending, goroutine blocks on gate
|
||||||
|
waitReflectionStatus(t, c, "pending")
|
||||||
|
|
||||||
|
_ = c.End() // Review -> Locked; gen1 still blocked
|
||||||
|
fast := &fakeReviewer{refl: ai.Reflection{Recap: "NEW", CarryForward: "new"}}
|
||||||
|
c.SetReviewer(fast)
|
||||||
|
driveToReview(t, c) // gen2 -> ready NEW
|
||||||
|
st := waitReflectionStatus(t, c, "ready")
|
||||||
|
if st.Reflection.Recap != "NEW" {
|
||||||
|
t.Fatalf("expected NEW, got %+v", st.Reflection)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(slow.gate) // release gen1; it must be discarded
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if got := c.State().Reflection.Recap; got != "NEW" {
|
||||||
|
t.Fatalf("stale gen1 overwrote reflection: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCarryForwardSurvivesRestart(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
|
first, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new: %v", err)
|
||||||
|
}
|
||||||
|
first.SetReviewer(&fakeReviewer{refl: ai.Reflection{Recap: "ok", CarryForward: "lead with tests"}})
|
||||||
|
if err := first.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("planning: %v", err)
|
||||||
|
}
|
||||||
|
if err := first.StartManualCommitment("a", "b", 25*time.Minute, nil); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
if err := first.Complete(); err != nil {
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
waitReflectionStatus(t, first, "ready")
|
||||||
|
if err := first.End(); err != nil {
|
||||||
|
t.Fatalf("end: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
if err := second.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("planning: %v", err)
|
||||||
|
}
|
||||||
|
st := second.State()
|
||||||
|
if st.Reflection == nil || st.Reflection.CarryForward != "lead with tests" {
|
||||||
|
t.Fatalf("carry-forward not restored onto planning: %+v", st.Reflection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,6 +74,25 @@ func readSummaries(path string) ([]SessionSummary, error) {
|
|||||||
return out, sc.Err()
|
return out, sc.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecentSessions returns up to n most-recent summaries from the audit chain in
|
||||||
|
// oldest-first order. A missing/empty chain or n <= 0 yields (nil, nil).
|
||||||
|
func RecentSessions(path string, n int) ([]SessionSummary, error) {
|
||||||
|
if n <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
all, err := readSummaries(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(all) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if len(all) > n {
|
||||||
|
all = all[len(all)-n:]
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AppendSession links s onto the chain (genesis prev_hash = 64 zeros), assigns
|
// AppendSession links s onto the chain (genesis prev_hash = 64 zeros), assigns
|
||||||
// the next contiguous Seq, computes its Hash, and appends one JSON line with an
|
// the next contiguous Seq, computes its Hash, and appends one JSON line with an
|
||||||
// fsync.
|
// fsync.
|
||||||
|
|||||||
@@ -75,3 +75,45 @@ func TestTamperDetected(t *testing.T) {
|
|||||||
t.Fatalf("tamper on line 2 should be reported, got %v", err)
|
t.Fatalf("tamper on line 2 should be reported, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRecentSessionsReturnsLastNOldestFirst(t *testing.T) {
|
||||||
|
path := auditFixture(t)
|
||||||
|
for _, id := range []string{"a", "b", "c"} {
|
||||||
|
if err := AppendSession(path, sampleSummary("session-"+id)); err != nil {
|
||||||
|
t.Fatalf("append %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := RecentSessions(path, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recent: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("want 2 summaries, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0].SessionID != "session-b" || got[1].SessionID != "session-c" {
|
||||||
|
t.Fatalf("want [b c] oldest-first, got [%s %s]", got[0].SessionID, got[1].SessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecentSessionsFewerThanN(t *testing.T) {
|
||||||
|
path := auditFixture(t)
|
||||||
|
_ = AppendSession(path, sampleSummary("session-a"))
|
||||||
|
got, err := RecentSessions(path, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recent: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].SessionID != "session-a" {
|
||||||
|
t.Fatalf("want [a], got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecentSessionsMissingOrZero(t *testing.T) {
|
||||||
|
if got, err := RecentSessions(auditFixture(t), 5); err != nil || got != nil {
|
||||||
|
t.Fatalf("missing chain: want (nil,nil), got (%+v,%v)", got, err)
|
||||||
|
}
|
||||||
|
path := auditFixture(t)
|
||||||
|
_ = AppendSession(path, sampleSummary("session-a"))
|
||||||
|
if got, err := RecentSessions(path, 0); err != nil || got != nil {
|
||||||
|
t.Fatalf("n=0: want (nil,nil), got (%+v,%v)", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ type Snapshot struct {
|
|||||||
OutcomePending string `json:"outcome_pending,omitempty"`
|
OutcomePending string `json:"outcome_pending,omitempty"`
|
||||||
|
|
||||||
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
||||||
|
|
||||||
|
ReflectionStatus string `json:"reflection_status,omitempty"`
|
||||||
|
ReflectionRecap string `json:"reflection_recap,omitempty"`
|
||||||
|
CarryForward string `json:"carry_forward,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultPath returns ~/.antidrift/state.json.
|
// DefaultPath returns ~/.antidrift/state.json.
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ input:focus { outline: 0; border-color: var(--accent); }
|
|||||||
|
|
||||||
/* Planning: standing-profile grounding indicator */
|
/* Planning: standing-profile grounding indicator */
|
||||||
.knowline { opacity: 0.85; }
|
.knowline { opacity: 0.85; }
|
||||||
|
.reflectline { opacity: 0.85; }
|
||||||
.link {
|
.link {
|
||||||
background: none; border: none; padding: 0; font: inherit; font-size: 12px;
|
background: none; border: none; padding: 0; font: inherit; font-size: 12px;
|
||||||
color: var(--accent); cursor: pointer; text-decoration: underline;
|
color: var(--accent); cursor: pointer; text-decoration: underline;
|
||||||
|
|||||||
@@ -179,6 +179,27 @@ function updatePlanningKnowledge(k) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reflectionBlock renders the reviewer's recap on the Review screen. idle/nil or
|
||||||
|
// an empty recap renders nothing; pending shows a quiet line; ready shows the
|
||||||
|
// one-line recap.
|
||||||
|
function reflectionBlock(refl) {
|
||||||
|
if (!refl) return '';
|
||||||
|
if (refl.status === 'pending') return `<div class="band"><div class="reflectline meta">reflecting…</div></div>`;
|
||||||
|
if (refl.status === 'ready' && refl.recap) {
|
||||||
|
return `<div class="band"><div class="reflectline">${refl.recap}</div></div>`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// updatePlanningReflection renders last session's carry-forward takeaway as a
|
||||||
|
// quiet one-liner on the planning screen. Nothing renders without one.
|
||||||
|
function updatePlanningReflection(refl) {
|
||||||
|
const el = document.getElementById('reflectBand');
|
||||||
|
if (!el) return;
|
||||||
|
if (!refl || !refl.carry_forward) { el.innerHTML = ''; return; }
|
||||||
|
el.innerHTML = `<span class="reflectline meta">Last time: ${refl.carry_forward}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
function render(state) {
|
function render(state) {
|
||||||
setStateAttr(state);
|
setStateAttr(state);
|
||||||
const rs = state.runtime_state;
|
const rs = state.runtime_state;
|
||||||
@@ -186,6 +207,7 @@ function render(state) {
|
|||||||
updatePlanningCoach(state.coach);
|
updatePlanningCoach(state.coach);
|
||||||
updatePlanningTasks(state.tasks);
|
updatePlanningTasks(state.tasks);
|
||||||
updatePlanningKnowledge(state.knowledge);
|
updatePlanningKnowledge(state.knowledge);
|
||||||
|
updatePlanningReflection(state.reflection);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (rs === 'active' && renderedState === 'active') {
|
if (rs === 'active' && renderedState === 'active') {
|
||||||
@@ -213,6 +235,7 @@ function render(state) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="band" id="tasksBand"></div>
|
<div class="band" id="tasksBand"></div>
|
||||||
<div class="band" id="knowBand"></div>
|
<div class="band" id="knowBand"></div>
|
||||||
|
<div class="band" id="reflectBand"></div>
|
||||||
<div class="band">
|
<div class="band">
|
||||||
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
||||||
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
|
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
|
||||||
@@ -239,6 +262,7 @@ function render(state) {
|
|||||||
updatePlanningCoach(state.coach);
|
updatePlanningCoach(state.coach);
|
||||||
updatePlanningTasks(state.tasks);
|
updatePlanningTasks(state.tasks);
|
||||||
updatePlanningKnowledge(state.knowledge);
|
updatePlanningKnowledge(state.knowledge);
|
||||||
|
updatePlanningReflection(state.reflection);
|
||||||
|
|
||||||
} else if (rs === 'active') {
|
} else if (rs === 'active') {
|
||||||
const c = state.commitment || {};
|
const c = state.commitment || {};
|
||||||
@@ -265,6 +289,7 @@ function render(state) {
|
|||||||
<p class="meta">${c.next_action || ''}</p>
|
<p class="meta">${c.next_action || ''}</p>
|
||||||
<p class="meta">done when: ${c.success_condition || ''}</p>
|
<p class="meta">done when: ${c.success_condition || ''}</p>
|
||||||
</div>
|
</div>
|
||||||
|
${reflectionBlock(state.reflection)}
|
||||||
${reviewSummary(state.evidence)}
|
${reviewSummary(state.evidence)}
|
||||||
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
|
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
|
||||||
document.getElementById('end').onclick = () => post('/end');
|
document.getElementById('end').onclick = () => post('/end');
|
||||||
|
|||||||
@@ -317,6 +317,51 @@ func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type stubReviewer struct {
|
||||||
|
refl ai.Reflection
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stubReviewer) Review(ctx context.Context, finished, history string) (ai.Reflection, error) {
|
||||||
|
return s.refl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
s.ctrl.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
|
||||||
|
r := s.Router()
|
||||||
|
_ = post(t, r, "/planning", "")
|
||||||
|
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}`
|
||||||
|
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := post(t, r, "/complete", ""); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("/complete code %d", w.Code)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if rv := s.ctrl.State().Reflection; rv != nil && rv.Status == "ready" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
js := s.stateJSON()
|
||||||
|
if !strings.Contains(js, `"recap":"held focus well"`) {
|
||||||
|
t.Fatalf("review payload missing recap: %s", js)
|
||||||
|
}
|
||||||
|
|
||||||
|
// End -> Locked -> Planning: the carry-forward should surface on planning.
|
||||||
|
if w := post(t, r, "/end", ""); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("/end code %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("/planning code %d", w.Code)
|
||||||
|
}
|
||||||
|
js2 := s.stateJSON()
|
||||||
|
if !strings.Contains(js2, `"carry_forward":"start in the editor"`) {
|
||||||
|
t.Fatalf("planning payload missing carry-forward: %s", js2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestKnowledgePathSelectionReloads(t *testing.T) {
|
func TestKnowledgePathSelectionReloads(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s := newTestServer(t)
|
||||||
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
|
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
|
||||||
|
|||||||
Reference in New Issue
Block a user