Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7621093eef | |||
| fefa72b909 | |||
| f73bb21c80 | |||
| 6ca796dee3 | |||
| 4e7676560d | |||
| 2977b903c2 | |||
| 75d31b5fa8 | |||
| 83bae00e45 | |||
| 7e9d00b80b | |||
| fccf856054 | |||
| 4e167744a0 |
@@ -23,6 +23,14 @@ go test ./...
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
M3 (drift interceptor): while a commitment is Active, the daemon watches the
|
||||||
|
focused window. A cheap local match against the session's allowed window classes
|
||||||
|
is authoritative for on-task; only unmatched windows are sent to the LLM drift
|
||||||
|
judge (debounced and cached per class, run asynchronously). When it judges you
|
||||||
|
off-task, the active view shows a dismissible interrupt: "Back to task", "This is
|
||||||
|
on task" (which adds the app to the session's allowed list), or "End session".
|
||||||
|
The drift judge degrades gracefully — without it, local matching still runs.
|
||||||
|
|
||||||
M2 (AI planning coach): in the Planning view, a rough intent is "sharpened"
|
M2 (AI planning coach): in the Planning view, a rough intent is "sharpened"
|
||||||
into a structured commitment (next action, success condition, timebox) by an
|
into a structured commitment (next action, success condition, timebox) by an
|
||||||
LLM CLI backend (claude or codex, selectable via `ANTIDRIFT_AI_BACKEND`). The
|
LLM CLI backend (claude or codex, selectable via `ANTIDRIFT_AI_BACKEND`). The
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ func main() {
|
|||||||
// (claude default, or codex). Misconfiguration degrades to no coach rather
|
// (claude default, or codex). Misconfiguration degrades to no coach rather
|
||||||
// than failing startup — manual planning still works.
|
// than failing startup — manual planning still works.
|
||||||
if backend, err := ai.NewBackend(os.Getenv("ANTIDRIFT_AI_BACKEND")); err != nil {
|
if backend, err := ai.NewBackend(os.Getenv("ANTIDRIFT_AI_BACKEND")); err != nil {
|
||||||
log.Printf("ai coach disabled: %v", err)
|
log.Printf("ai disabled: %v", err)
|
||||||
} else {
|
} else {
|
||||||
ctrl.SetCoach(ai.NewService(backend))
|
svc := ai.NewService(backend)
|
||||||
log.Printf("ai coach: %s backend", backend.Name())
|
ctrl.SetCoach(svc)
|
||||||
|
ctrl.SetDriftJudge(svc)
|
||||||
|
log.Printf("ai: %s backend (coach + drift judge)", backend.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
src := evidence.NewSource()
|
src := evidence.NewSource()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
|||||||
|
# M3 — Drift Interceptor — Design
|
||||||
|
|
||||||
|
Date: 2026-05-31
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
M3 makes drift *visible and interruptive* while a commitment is Active. The
|
||||||
|
daemon watches the focused window (the M1 evidence stream) and, when the user
|
||||||
|
wanders off-task, surfaces a dismissible interrupt in the active view asking
|
||||||
|
them to refocus, justify ("this is on task"), or end the session.
|
||||||
|
|
||||||
|
It uses **cheap local matching first** and the **LLM only for the ambiguous
|
||||||
|
cases**, keeping the slow CLI off the common path. This adds the second live AI
|
||||||
|
role — `JudgeDrift` — at the **cortex** layer: it judges at a decision point the
|
||||||
|
state machine exposes (an active session observing a window), but it never forces
|
||||||
|
a transition. Enforcement (minimizing/blocking) remains deferred to M8; M3's
|
||||||
|
"friction" is UI-only.
|
||||||
|
|
||||||
|
The ambient **Nudge** role is deliberately **out of scope** here; it is the fast
|
||||||
|
follow-on (M3.5).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In scope (M3):**
|
||||||
|
|
||||||
|
- Local allowed-context matching ported from `legacy/src/context.rs` into the
|
||||||
|
`evidence` package (window-class + title-substring matching).
|
||||||
|
- The coach (`ai` Coach, from M2) extended to also propose **allowed window
|
||||||
|
classes**; the planning form shows them as an editable field.
|
||||||
|
- A new `DriftJudge` AI role behind a leaf-preserving interface; `Service`
|
||||||
|
implements it via the same CLI backends as M2.
|
||||||
|
- Live drift judgment wired into the `RecordWindow` hot path: debounced
|
||||||
|
(≤ 1 judgment / ~10s) and **cached per window-class**, run in a background
|
||||||
|
goroutine, surfaced over SSE.
|
||||||
|
- An override loop: "this is on task" appends the window-class to the session's
|
||||||
|
allowed-context so it matches locally thereafter.
|
||||||
|
- Active-view drift interrupt UI; `POST /refocus` and `POST /ontask` routes.
|
||||||
|
- Allowed-context persisted in the snapshot (survives restart).
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
|
||||||
|
- The ambient `Nudge` role (M3.5).
|
||||||
|
- Domain/URL and command matching. `AllowedContext` keeps those fields, but the
|
||||||
|
X11 sensor only yields window class + title — no browser URLs or process args —
|
||||||
|
so M3 matches on class + title only.
|
||||||
|
- Any enforcement (window-minimize, blocking): that is M8.
|
||||||
|
- Persisting the drift *verdict*. See "Persistence" — only allowed-context is
|
||||||
|
durable; the verdict recomputes after restart.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
M3 extends the ports-and-adapters shape established in M1/M2. The `ai` package
|
||||||
|
gains a second role (`DriftJudge`) but stays a **leaf package**; the `evidence`
|
||||||
|
package gains pure matching logic; `session.Controller` orchestrates the
|
||||||
|
debounced async judgment exactly as it orchestrates the M2 coach; the browser
|
||||||
|
renders over the existing SSE stream.
|
||||||
|
|
||||||
|
### The drift pipeline (per window observation, while Active)
|
||||||
|
|
||||||
|
```
|
||||||
|
window observation ──▶ local match against allowed-context?
|
||||||
|
│
|
||||||
|
matched ───┴─── not matched
|
||||||
|
│ │
|
||||||
|
on-task debounce + per-class cache
|
||||||
|
(clear drift) │
|
||||||
|
fresh ──┴── cached
|
||||||
|
│ │
|
||||||
|
JudgeDrift (bg) use cached verdict
|
||||||
|
│
|
||||||
|
verdict on_task / drifting
|
||||||
|
│
|
||||||
|
drift state ──▶ SSE ──▶ active-view interrupt
|
||||||
|
```
|
||||||
|
|
||||||
|
A local match short-circuits to **on-task** with no LLM call. This is
|
||||||
|
authoritative: a window in an allowed class is treated as on-task even if the
|
||||||
|
user is technically idling there. Only **unmatched** windows reach the judge.
|
||||||
|
|
||||||
|
### `evidence` — local matching (ported from Rust)
|
||||||
|
|
||||||
|
New `internal/evidence/context.go`, porting `legacy/src/context.rs`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// MatchesAllowed reports whether a window (class/title) is on-task per ctx.
|
||||||
|
// M3 uses class + title only; domains/commands are matched by the helpers but
|
||||||
|
// have no data source yet.
|
||||||
|
func MatchesAllowed(ctx domain.AllowedContext, class, title string) bool
|
||||||
|
```
|
||||||
|
|
||||||
|
with helpers `windowClassAllowed`, `windowTitleAllowed` (and `domainAllowed`,
|
||||||
|
`commandAllowed` ported for completeness/tests, unused on the live path):
|
||||||
|
|
||||||
|
- class: trimmed, casefolded, **exact** match against `WindowClasses`.
|
||||||
|
- title: trimmed, casefolded **substring** match against
|
||||||
|
`WindowTitleSubstrings`.
|
||||||
|
- domain: exact or subdomain (`docs.github.com` matches `github.com`),
|
||||||
|
trailing-dot/whitespace/case normalized.
|
||||||
|
- command: executable basename, casefolded.
|
||||||
|
|
||||||
|
`MatchesAllowed` returns true if class OR title matches. `evidence` may import
|
||||||
|
`domain` (for `AllowedContext`) — `domain` is a pure leaf, so no cycle.
|
||||||
|
|
||||||
|
### `ai` — `DriftJudge`, leaf-preserving
|
||||||
|
|
||||||
|
The M2 review established that `ai` imports nothing from the app. To keep that,
|
||||||
|
`JudgeDrift` takes **primitives**, not `domain`/`evidence` types — the controller
|
||||||
|
formats the commitment and window into strings before calling.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Verdict is the drift judge's call on a single window.
|
||||||
|
type Verdict struct {
|
||||||
|
OnTask bool
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DriftJudge decides whether the current window is on-task for a commitment.
|
||||||
|
type DriftJudge interface {
|
||||||
|
JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Service` (from M2) also implements `DriftJudge`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Service) JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
It builds a strict-JSON prompt, calls `backend.Run`, and parses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"on_task": false, "reason": "Reddit is unrelated to drafting the report"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `buildDriftPrompt(commitment, class, title)` — gives the model the commitment
|
||||||
|
description and the current window, asks for ONLY the JSON object above.
|
||||||
|
- `parseVerdict(s string) (Verdict, error)` — reuses `extractJSON`; unmarshals
|
||||||
|
`{on_task bool, reason string}`; trims `reason`. A missing/empty body or
|
||||||
|
unparseable output returns an error (sentinel `ErrInvalidVerdict`); the caller
|
||||||
|
degrades by leaving drift state unchanged.
|
||||||
|
|
||||||
|
### Coach extension (allowed window classes)
|
||||||
|
|
||||||
|
`ai.Proposal` gains a field; the coach prompt asks for it:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Proposal struct {
|
||||||
|
NextAction string
|
||||||
|
SuccessCondition string
|
||||||
|
TimeboxSecs int64
|
||||||
|
AllowedWindowClasses []string // NEW
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Prompt JSON shape extends to:
|
||||||
|
`{"next_action":..., "success_condition":..., "timebox_minutes":..., "allowed_window_classes":["code","firefox"]}`.
|
||||||
|
`parseProposal` reads the new array (optional — absent/empty is valid; the user
|
||||||
|
can fill it in). `ProposalView` (session) gains `allowed_window_classes`.
|
||||||
|
|
||||||
|
### `session.Controller` — orchestration
|
||||||
|
|
||||||
|
New ephemeral + durable state on the controller:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// durable (persisted): the active session's allowed classes, mutable via override
|
||||||
|
allowedClasses []string
|
||||||
|
|
||||||
|
// ephemeral drift machinery
|
||||||
|
judge ai.DriftJudge
|
||||||
|
driftStatus string // "idle" | "pending" | "ontask" | "drifting"
|
||||||
|
driftReason string
|
||||||
|
driftGen int
|
||||||
|
lastJudgedAt time.Time
|
||||||
|
judgedClasses map[string]ai.Verdict // per-class cache for this session
|
||||||
|
```
|
||||||
|
|
||||||
|
**Injection:** `SetDriftJudge(ai.DriftJudge)` (mirrors `SetCoach`). Nil judge ⇒
|
||||||
|
drift stays idle; local matching still runs (a matched window shows on-task).
|
||||||
|
|
||||||
|
**Hot-path hook** in `RecordWindow` (while holding the lock, after `applyEvent`,
|
||||||
|
only when Active):
|
||||||
|
|
||||||
|
1. Build `domain.AllowedContext{WindowClasses: c.allowedClasses}` and test
|
||||||
|
`MatchesAllowed(ac, class, title)`. On match ⇒ set `driftStatus=ontask`,
|
||||||
|
clear reason. Done (no LLM). (Only `WindowClasses` is populated in M3, since
|
||||||
|
the coach proposes classes; title substrings are matched by the helper but
|
||||||
|
left empty.)
|
||||||
|
2. Else consult per-class cache: if `judgedClasses[class]` exists ⇒ apply it
|
||||||
|
(`ontask`/`drifting` + reason). Done.
|
||||||
|
3. Else **debounce**: if `now.Sub(lastJudgedAt) < driftDebounce` (10s) ⇒ leave
|
||||||
|
current state. Done.
|
||||||
|
4. Else launch judgment: bump `driftGen`, capture `gen`, set
|
||||||
|
`lastJudgedAt=now`, `driftStatus=pending`, capture `judge` + commitment text
|
||||||
|
+ class/title, then (after unlocking, per the M2 pattern) run the judge in a
|
||||||
|
goroutine with a `driftTimeout` (30s) context.
|
||||||
|
|
||||||
|
**Goroutine completion** (re-acquire lock): discard if `gen != driftGen` or no
|
||||||
|
longer Active (stale — commitment ended/changed). Else cache
|
||||||
|
`judgedClasses[class]=verdict`; if `class` is still the current window's class,
|
||||||
|
set `driftStatus` to `ontask`/`drifting` + reason. Unlock, `notify()`.
|
||||||
|
|
||||||
|
All `notify()` calls fire with the mutex released — identical discipline to M2's
|
||||||
|
`RequestCoach` and the existing focus path.
|
||||||
|
|
||||||
|
**Override / dismiss:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// OnTask appends the current window class to the session allowed-context, clears
|
||||||
|
// drift, and persists. The class now matches locally and is never re-judged.
|
||||||
|
func (c *Controller) OnTask() error
|
||||||
|
|
||||||
|
// Refocus clears the current drift verdict without changing allowed-context.
|
||||||
|
// The same off-task class may be judged again later.
|
||||||
|
func (c *Controller) Refocus() error
|
||||||
|
```
|
||||||
|
|
||||||
|
Both return `ErrNotActive` outside the Active state. `OnTask` appends to
|
||||||
|
`allowedClasses`, drops any cached drifting verdict for that class, sets
|
||||||
|
`driftStatus=ontask`, and persists the snapshot.
|
||||||
|
|
||||||
|
**Commitment start:** `StartManualCommitment` gains an `allowedClasses []string`
|
||||||
|
parameter, stored on the controller and persisted. Drift caches/state reset when
|
||||||
|
a session starts and when it ends (`End`).
|
||||||
|
|
||||||
|
### State projection
|
||||||
|
|
||||||
|
`State` gains a drift view, projected **only while Active**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type DriftView struct {
|
||||||
|
Status string `json:"status"` // idle | pending | ontask | drifting
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
// added to State:
|
||||||
|
// Drift *DriftView `json:"drift,omitempty"`
|
||||||
|
```
|
||||||
|
|
||||||
|
`CommitmentView` is unchanged; `ProposalView` gains
|
||||||
|
`AllowedWindowClasses []string json:"allowed_window_classes,omitempty"`.
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
`store.Snapshot` gains `AllowedWindowClasses []string`. `persistLocked` writes
|
||||||
|
the controller's `allowedClasses`; `New` restores them when a live Active session
|
||||||
|
is rebuilt. **The drift verdict is NOT persisted** — on restart it recomputes
|
||||||
|
from the first post-restart window observation (≤ one debounce window). This
|
||||||
|
avoids showing a stale "drifting" interrupt for a window the user has already
|
||||||
|
navigated away from, at the cost of a brief idle state after restart. This is a
|
||||||
|
deliberate refinement of "persist session state across restart".
|
||||||
|
|
||||||
|
### `web` layer
|
||||||
|
|
||||||
|
- `commitmentRequest` gains `AllowedWindowClasses []string json:"allowed_window_classes"`;
|
||||||
|
`handleCommitment` passes them to `StartManualCommitment`.
|
||||||
|
- `POST /refocus` → `ctrl.Refocus()`; `POST /ontask` → `ctrl.OnTask()`. Both via
|
||||||
|
the existing `respond` helper (so `ErrNotActive` maps to 400, success
|
||||||
|
broadcasts).
|
||||||
|
|
||||||
|
### UI (`internal/web/static/index.html`)
|
||||||
|
|
||||||
|
**Planning view:** add an "Allowed apps" input (comma-separated window classes),
|
||||||
|
pre-filled from `coach.proposal.allowed_window_classes` when a proposal lands
|
||||||
|
(same one-time, non-clobbering pre-fill as M2). The Start commitment POST
|
||||||
|
includes the parsed list.
|
||||||
|
|
||||||
|
**Active view:** when `state.drift.status === 'drifting'`, render an interrupt
|
||||||
|
block above/around the timer:
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠ Possible drift
|
||||||
|
<reason>
|
||||||
|
[ Back to task ] [ This is on task ] [ End session ]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Back to task` → `POST /refocus`
|
||||||
|
- `This is on task` → `POST /ontask`
|
||||||
|
- `End session` → the existing `POST /complete` (Active → Review), same as the
|
||||||
|
active view's current Complete button
|
||||||
|
|
||||||
|
`status === 'pending'` may show a subtle "checking…" hint; `ontask`/`idle` show
|
||||||
|
nothing. The active view already rebuilds on SSE ticks and runs a countdown
|
||||||
|
timer; the drift block must integrate without resetting the timer — apply the
|
||||||
|
same partial-update care used for the planning coach (update the drift region
|
||||||
|
without tearing down the countdown). The interrupt is **non-modal** (it cannot
|
||||||
|
lock the user out — enforcement is M8).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
No new configuration. The drift judge reuses the M2 backend selected by
|
||||||
|
`ANTIDRIFT_AI_BACKEND`; the daemon wires the single `Service` into both
|
||||||
|
`SetCoach` and `SetDriftJudge`. Debounce (10s) and timeout (30s) are constants.
|
||||||
|
|
||||||
|
## Error Handling and Degradation
|
||||||
|
|
||||||
|
| Condition | Result |
|
||||||
|
| --------- | ------ |
|
||||||
|
| Nil judge (unwired/misconfig) | Local matching still runs; unmatched windows leave drift `idle` — never blocks |
|
||||||
|
| CLI failure / timeout / unparseable verdict | Judgment discarded; drift state unchanged (no false "drifting"); logged server-side |
|
||||||
|
| Judge slow, user changes window | Per-class cache + generation guard; stale results discarded |
|
||||||
|
| `/refocus` or `/ontask` outside Active | `ErrNotActive` → HTTP 400 |
|
||||||
|
|
||||||
|
Drift judgment failures **never** fabricate a drift verdict; the safe default is
|
||||||
|
"not drifting".
|
||||||
|
|
||||||
|
## Package Layout Changes
|
||||||
|
|
||||||
|
| Package | Change |
|
||||||
|
| ------- | ------ |
|
||||||
|
| `evidence` | New `context.go` (matching helpers + `MatchesAllowed`) + tests ported from `legacy/src/context.rs` |
|
||||||
|
| `ai` | `Verdict`, `DriftJudge`, `Service.JudgeDrift`, `buildDriftPrompt`, `parseVerdict`, `ErrInvalidVerdict`; `Proposal.AllowedWindowClasses` + coach prompt/parse updates |
|
||||||
|
| `session` | `allowedClasses` (persisted), drift machinery (judge, status, reason, gen, debounce, per-class cache); `SetDriftJudge`; `RecordWindow` hook; `OnTask`/`Refocus`; `StartManualCommitment` allowed-classes param; `DriftView` + `State.Drift`; `ProposalView.AllowedWindowClasses`; snapshot field |
|
||||||
|
| `store` | `Snapshot.AllowedWindowClasses` |
|
||||||
|
| `web` | `commitmentRequest.AllowedWindowClasses`; `POST /refocus`, `POST /ontask` |
|
||||||
|
| `web/static/index.html` | planning "allowed apps" field; active-view drift interrupt; partial-update care |
|
||||||
|
| `cmd/antidriftd` | `ctrl.SetDriftJudge(service)` alongside `SetCoach` |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
**`evidence`:** port the `context.rs` test table (class exact/casefold, title
|
||||||
|
substring, domain exact/subdomain/normalization, command basename) plus
|
||||||
|
`MatchesAllowed` (class-only match, title-only match, neither).
|
||||||
|
|
||||||
|
**`ai`:** `parseVerdict` (valid on_task true/false, chatty-wrapped, missing
|
||||||
|
fields → error); `Service.JudgeDrift` over a `fakeBackend`; `parseProposal` now
|
||||||
|
reads `allowed_window_classes` (present, absent, empty); arg/prompt building does
|
||||||
|
not regress. No real CLI.
|
||||||
|
|
||||||
|
**`session`** (with a fake `DriftJudge`):
|
||||||
|
|
||||||
|
- Local match ⇒ `ontask`, judge never called.
|
||||||
|
- Unmatched ⇒ `pending` then `drifting`/`ontask` per the fake's verdict.
|
||||||
|
- Per-class cache: second observation of a judged class does not call the judge
|
||||||
|
again.
|
||||||
|
- Debounce: rapid unmatched observations within 10s trigger at most one judge
|
||||||
|
call (drive `clock` via the existing `SetClock`).
|
||||||
|
- Stale generation: slow judge result discarded after the session ends / a new
|
||||||
|
one starts (gate the fake on a channel, as in the M2 coach test).
|
||||||
|
- `OnTask` appends the class, clears drift, and a subsequent observation of that
|
||||||
|
class matches locally (no judge call); persisted across reload.
|
||||||
|
- `Refocus` clears drift without mutating allowed-context.
|
||||||
|
- Restart restores `allowedClasses` from the snapshot; drift starts `idle`.
|
||||||
|
- Nil judge: unmatched window leaves drift `idle`, no panic.
|
||||||
|
|
||||||
|
**`web`:** `/refocus` and `/ontask` happy paths + outside-Active 400; commitment
|
||||||
|
request carries allowed classes into the controller.
|
||||||
|
|
||||||
|
All tests use fakes; **no test spawns a real CLI**. `go test -race ./...` stays
|
||||||
|
clean.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- `evidence` matching ported with tests.
|
||||||
|
- `ai` `DriftJudge` + coach allowed-classes extension, with tests.
|
||||||
|
- Controller drift pipeline (local-first, debounced, cached, async) with
|
||||||
|
override/dismiss and persistence, race-clean.
|
||||||
|
- `/refocus`, `/ontask` routes; planning allowed-apps field; active-view drift
|
||||||
|
interrupt that doesn't disrupt the timer.
|
||||||
|
- Daemon wires `SetDriftJudge`.
|
||||||
|
- `go test -race ./...` and `go vet ./...` pass; manual smoke: start a session
|
||||||
|
with an allowed class, switch to an unrelated app, see the interrupt, exercise
|
||||||
|
both buttons.
|
||||||
|
- README/roadmap note M3 complete.
|
||||||
+32
-1
@@ -33,12 +33,43 @@ func buildPrompt(intent string) string {
|
|||||||
return `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment.
|
return `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment.
|
||||||
|
|
||||||
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||||
{"next_action": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>}
|
{"next_action": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>, "allowed_window_classes": ["<app/window class that is on-task, e.g. code, firefox>"]}
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- next_action: a single concrete imperative action, doable now.
|
- next_action: a single concrete imperative action, doable now.
|
||||||
- success_condition: observable and verifiable; how you'd know it is done.
|
- success_condition: observable and verifiable; how you'd know it is done.
|
||||||
- timebox_minutes: a realistic integer number of minutes for the action.
|
- timebox_minutes: a realistic integer number of minutes for the action.
|
||||||
|
- allowed_window_classes: a short list of window/application class names that count as on-task for this action (lowercase, e.g. "code", "firefox"). May be empty.
|
||||||
|
|
||||||
User intent: ` + intent
|
User intent: ` + intent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DriftJudge decides whether the current window is on-task for a commitment.
|
||||||
|
// It takes primitives, not domain/evidence types, so ai stays a leaf package.
|
||||||
|
type DriftJudge interface {
|
||||||
|
JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JudgeDrift makes Service satisfy DriftJudge over the same backend as Coach.
|
||||||
|
func (s *Service) JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error) {
|
||||||
|
out, err := s.backend.Run(ctx, buildDriftPrompt(commitment, windowClass, windowTitle))
|
||||||
|
if err != nil {
|
||||||
|
return Verdict{}, err
|
||||||
|
}
|
||||||
|
return parseVerdict(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDriftPrompt(commitment, class, title string) string {
|
||||||
|
return `You are a focus monitor. The user committed to a task. Decide whether their CURRENT window is on-task or a distraction.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||||
|
{"on_task": <true or false>, "reason": "<short explanation, one sentence>"}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- on_task: true if the window plausibly serves the commitment, false if it is a distraction.
|
||||||
|
- reason: one short sentence. REQUIRED when on_task is false.
|
||||||
|
|
||||||
|
Commitment: ` + commitment + `
|
||||||
|
Current window class: ` + class + `
|
||||||
|
Current window title: ` + title
|
||||||
|
}
|
||||||
|
|||||||
+14
-1
@@ -16,6 +16,7 @@ type Proposal struct {
|
|||||||
NextAction string
|
NextAction string
|
||||||
SuccessCondition string
|
SuccessCondition string
|
||||||
TimeboxSecs int64
|
TimeboxSecs int64
|
||||||
|
AllowedWindowClasses []string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -64,6 +65,7 @@ type rawProposal struct {
|
|||||||
NextAction string `json:"next_action"`
|
NextAction string `json:"next_action"`
|
||||||
SuccessCondition string `json:"success_condition"`
|
SuccessCondition string `json:"success_condition"`
|
||||||
TimeboxMinutes int64 `json:"timebox_minutes"`
|
TimeboxMinutes int64 `json:"timebox_minutes"`
|
||||||
|
AllowedWindowClasses []string `json:"allowed_window_classes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseProposal extracts and validates a Proposal from raw CLI output.
|
// parseProposal extracts and validates a Proposal from raw CLI output.
|
||||||
@@ -91,5 +93,16 @@ func parseProposal(s string) (Proposal, error) {
|
|||||||
if na == "" || sc == "" || raw.TimeboxMinutes <= 0 {
|
if na == "" || sc == "" || raw.TimeboxMinutes <= 0 {
|
||||||
return Proposal{}, ErrInvalidProposal
|
return Proposal{}, ErrInvalidProposal
|
||||||
}
|
}
|
||||||
return Proposal{NextAction: na, SuccessCondition: sc, TimeboxSecs: raw.TimeboxMinutes * 60}, nil
|
classes := make([]string, 0, len(raw.AllowedWindowClasses))
|
||||||
|
for _, cls := range raw.AllowedWindowClasses {
|
||||||
|
if t := strings.TrimSpace(cls); t != "" {
|
||||||
|
classes = append(classes, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Proposal{
|
||||||
|
NextAction: na,
|
||||||
|
SuccessCondition: sc,
|
||||||
|
TimeboxSecs: raw.TimeboxMinutes * 60,
|
||||||
|
AllowedWindowClasses: classes,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,3 +69,23 @@ func TestParseProposal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseProposalReadsAllowedClasses(t *testing.T) {
|
||||||
|
p, err := parseProposal(`{"next_action":"a","success_condition":"b","timebox_minutes":20,"allowed_window_classes":["code"," firefox ",""]}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(p.AllowedWindowClasses) != 2 || p.AllowedWindowClasses[0] != "code" || p.AllowedWindowClasses[1] != "firefox" {
|
||||||
|
t.Fatalf("classes wrong (blanks should drop, trim applied): %#v", p.AllowedWindowClasses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseProposalAllowedClassesOptional(t *testing.T) {
|
||||||
|
p, err := parseProposal(`{"next_action":"a","success_condition":"b","timebox_minutes":20}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(p.AllowedWindowClasses) != 0 {
|
||||||
|
t.Fatalf("absent field should yield empty slice, got %#v", p.AllowedWindowClasses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verdict is the drift judge's call on a single window.
|
||||||
|
type Verdict struct {
|
||||||
|
OnTask bool
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidVerdict marks a parseable-but-unusable judge response.
|
||||||
|
var ErrInvalidVerdict = errors.New("ai: invalid verdict")
|
||||||
|
|
||||||
|
type rawVerdict struct {
|
||||||
|
OnTask bool `json:"on_task"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVerdict extracts and validates a Verdict from raw CLI output. It reuses
|
||||||
|
// extractJSON and the shared empty/no-JSON sentinels. A drifting verdict with no
|
||||||
|
// reason is unusable for the UI and is rejected as ErrInvalidVerdict.
|
||||||
|
func parseVerdict(s string) (Verdict, error) {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return Verdict{}, ErrEmptyResponse
|
||||||
|
}
|
||||||
|
if strings.IndexByte(s, '{') < 0 {
|
||||||
|
return Verdict{}, ErrNoJSON
|
||||||
|
}
|
||||||
|
jsonStr, err := extractJSON(s)
|
||||||
|
if err != nil {
|
||||||
|
return Verdict{}, fmt.Errorf("%w: %v", ErrInvalidVerdict, err)
|
||||||
|
}
|
||||||
|
var raw rawVerdict
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||||||
|
return Verdict{}, fmt.Errorf("%w: %v", ErrInvalidVerdict, err)
|
||||||
|
}
|
||||||
|
v := Verdict{OnTask: raw.OnTask, Reason: strings.TrimSpace(raw.Reason)}
|
||||||
|
if !v.OnTask && v.Reason == "" {
|
||||||
|
return Verdict{}, ErrInvalidVerdict
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseVerdictOnTask(t *testing.T) {
|
||||||
|
v, err := parseVerdict(`{"on_task": true, "reason": ""}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if !v.OnTask {
|
||||||
|
t.Fatalf("expected on-task, got %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVerdictDriftingChatty(t *testing.T) {
|
||||||
|
v, err := parseVerdict("Sure! here is the call:\n{\"on_task\": false, \"reason\": \"Reddit is unrelated\"}\nhope that helps")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if v.OnTask || v.Reason != "Reddit is unrelated" {
|
||||||
|
t.Fatalf("bad verdict: %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVerdictDriftingNeedsReason(t *testing.T) {
|
||||||
|
if _, err := parseVerdict(`{"on_task": false}`); !errors.Is(err, ErrInvalidVerdict) {
|
||||||
|
t.Fatalf("want ErrInvalidVerdict for reasonless drift, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVerdictEmpty(t *testing.T) {
|
||||||
|
if _, err := parseVerdict(" "); !errors.Is(err, ErrEmptyResponse) {
|
||||||
|
t.Fatalf("want ErrEmptyResponse, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVerdictNoJSON(t *testing.T) {
|
||||||
|
if _, err := parseVerdict("I cannot help"); !errors.Is(err, ErrNoJSON) {
|
||||||
|
t.Fatalf("want ErrNoJSON, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceJudgeDrift(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `{"on_task": false, "reason": "YouTube is off-task"}`}
|
||||||
|
v, err := NewService(fb).JudgeDrift(context.Background(), "write the report", "firefox", "YouTube")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("judge: %v", err)
|
||||||
|
}
|
||||||
|
if v.OnTask || v.Reason == "" {
|
||||||
|
t.Fatalf("bad verdict: %+v", v)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "write the report") || !strings.Contains(fb.gotPrompt, "YouTube") {
|
||||||
|
t.Fatalf("prompt should embed commitment and window: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceJudgeDriftBackendError(t *testing.T) {
|
||||||
|
fb := &fakeBackend{err: errors.New("boom")}
|
||||||
|
if _, err := NewService(fb).JudgeDrift(context.Background(), "x", "c", "t"); err == nil {
|
||||||
|
t.Fatal("want backend error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"antidrift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MatchesAllowed reports whether a window (class/title) is on-task per ctx.
|
||||||
|
// M3 matches on window class and title only; the domain and command helpers are
|
||||||
|
// ported for completeness and tests but have no live data source yet.
|
||||||
|
func MatchesAllowed(ctx domain.AllowedContext, class, title string) bool {
|
||||||
|
return windowClassAllowed(ctx, class) || windowTitleAllowed(ctx, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowClassAllowed(ctx domain.AllowedContext, candidate string) bool {
|
||||||
|
candidate = normalizeCasefolded(candidate)
|
||||||
|
if candidate == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, allowed := range ctx.WindowClasses {
|
||||||
|
if normalizeCasefolded(allowed) == candidate {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowTitleAllowed(ctx domain.AllowedContext, candidate string) bool {
|
||||||
|
candidate = normalizeCasefolded(candidate)
|
||||||
|
if candidate == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, allowed := range ctx.WindowTitleSubstrings {
|
||||||
|
a := normalizeCasefolded(allowed)
|
||||||
|
if a != "" && strings.Contains(candidate, a) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainAllowed(ctx domain.AllowedContext, candidate string) bool {
|
||||||
|
candidate = normalizeDomain(candidate)
|
||||||
|
if candidate == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, allowed := range ctx.Domains {
|
||||||
|
a := normalizeDomain(allowed)
|
||||||
|
if a == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if candidate == a {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(candidate, a) && strings.HasSuffix(candidate[:len(candidate)-len(a)], ".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandAllowed(ctx domain.AllowedContext, candidate string) bool {
|
||||||
|
candidate = executableBasename(candidate)
|
||||||
|
if candidate == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, allowed := range ctx.Commands {
|
||||||
|
if executableBasename(allowed) == candidate {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDomain(v string) string {
|
||||||
|
return strings.ToLower(strings.TrimRight(strings.TrimSpace(v), "."))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCasefolded(v string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// executableBasename returns the lowercased basename of the first whitespace-
|
||||||
|
// separated token (the executable), splitting on both / and \.
|
||||||
|
func executableBasename(command string) string {
|
||||||
|
fields := strings.Fields(command)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
exe := fields[0]
|
||||||
|
if i := strings.LastIndexAny(exe, `/\`); i >= 0 {
|
||||||
|
exe = exe[i+1:]
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(exe))
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"antidrift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
|
||||||
|
ctx := domain.AllowedContext{WindowClasses: []string{" code "}}
|
||||||
|
if !windowClassAllowed(ctx, " Code ") {
|
||||||
|
t.Fatal("class should match case-insensitively after trim")
|
||||||
|
}
|
||||||
|
if windowClassAllowed(ctx, "firefox") {
|
||||||
|
t.Fatal("unrelated class must not match")
|
||||||
|
}
|
||||||
|
if windowClassAllowed(ctx, " ") {
|
||||||
|
t.Fatal("empty candidate must not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindowTitleMatchesSubstring(t *testing.T) {
|
||||||
|
ctx := domain.AllowedContext{WindowTitleSubstrings: []string{" antidrift "}}
|
||||||
|
if !windowTitleAllowed(ctx, "Commitment OS - AntiDrift") {
|
||||||
|
t.Fatal("title substring should match after trim/casefold")
|
||||||
|
}
|
||||||
|
if windowTitleAllowed(ctx, "random video") {
|
||||||
|
t.Fatal("unrelated title must not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainMatchesExactAndSubdomain(t *testing.T) {
|
||||||
|
ctx := domain.AllowedContext{Domains: []string{" GitHub.COM. "}}
|
||||||
|
if !domainAllowed(ctx, "github.com") {
|
||||||
|
t.Fatal("exact domain should match")
|
||||||
|
}
|
||||||
|
if !domainAllowed(ctx, " DOCS.GITHUB.COM. ") {
|
||||||
|
t.Fatal("subdomain should match after normalization")
|
||||||
|
}
|
||||||
|
if domainAllowed(ctx, "evilgithub.com") {
|
||||||
|
t.Fatal("suffix-without-dot must not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandMatchesExecutableBasename(t *testing.T) {
|
||||||
|
ctx := domain.AllowedContext{Commands: []string{"cargo"}}
|
||||||
|
if !commandAllowed(ctx, "/home/felixm/.cargo/bin/cargo test") {
|
||||||
|
t.Fatal("basename of full path should match")
|
||||||
|
}
|
||||||
|
if !commandAllowed(ctx, "cargo") {
|
||||||
|
t.Fatal("bare command should match")
|
||||||
|
}
|
||||||
|
if commandAllowed(ctx, "/usr/bin/git status") {
|
||||||
|
t.Fatal("unrelated command must not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesAllowedClassOrTitle(t *testing.T) {
|
||||||
|
ctx := domain.AllowedContext{
|
||||||
|
WindowClasses: []string{"code"},
|
||||||
|
WindowTitleSubstrings: []string{"antidrift"},
|
||||||
|
}
|
||||||
|
if !MatchesAllowed(ctx, "Code", "anything") {
|
||||||
|
t.Fatal("class match should satisfy MatchesAllowed")
|
||||||
|
}
|
||||||
|
if !MatchesAllowed(ctx, "firefox", "my AntiDrift tab") {
|
||||||
|
t.Fatal("title match should satisfy MatchesAllowed")
|
||||||
|
}
|
||||||
|
if MatchesAllowed(ctx, "firefox", "youtube") {
|
||||||
|
t.Fatal("neither match must be off-task")
|
||||||
|
}
|
||||||
|
if MatchesAllowed(domain.AllowedContext{}, "code", "antidrift") {
|
||||||
|
t.Fatal("empty context matches nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
+207
-1
@@ -10,6 +10,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -36,8 +37,22 @@ const (
|
|||||||
coachError = "error"
|
coachError = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
driftDebounce = 10 * time.Second
|
||||||
|
driftTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
driftIdle = "idle"
|
||||||
|
driftPending = "pending"
|
||||||
|
driftOnTask = "ontask"
|
||||||
|
driftDrifting = "drifting"
|
||||||
|
)
|
||||||
|
|
||||||
var ErrNotPlanning = errors.New("session: coaching is only available while planning")
|
var ErrNotPlanning = errors.New("session: coaching is only available while planning")
|
||||||
|
|
||||||
|
var ErrNotActive = errors.New("session: only available while a commitment is active")
|
||||||
|
|
||||||
// bucketKey identifies a time bucket; Title is the scrubbed title.
|
// bucketKey identifies a time bucket; Title is the scrubbed title.
|
||||||
type bucketKey struct{ Class, Title string }
|
type bucketKey struct{ Class, Title string }
|
||||||
|
|
||||||
@@ -72,6 +87,14 @@ type Controller struct {
|
|||||||
coachProposal *ai.Proposal
|
coachProposal *ai.Proposal
|
||||||
coachErr string
|
coachErr string
|
||||||
coachGen int
|
coachGen int
|
||||||
|
|
||||||
|
allowedClasses []string // durable: the active session's allowed window classes
|
||||||
|
judge ai.DriftJudge
|
||||||
|
driftStatus string
|
||||||
|
driftReason string
|
||||||
|
driftGen int
|
||||||
|
lastJudgedAt time.Time
|
||||||
|
judgedClasses map[string]ai.Verdict
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommitmentView is the UI projection of the active commitment.
|
// CommitmentView is the UI projection of the active commitment.
|
||||||
@@ -87,6 +110,14 @@ type ProposalView struct {
|
|||||||
NextAction string `json:"next_action"`
|
NextAction string `json:"next_action"`
|
||||||
SuccessCondition string `json:"success_condition"`
|
SuccessCondition string `json:"success_condition"`
|
||||||
TimeboxSecs int64 `json:"timebox_secs"`
|
TimeboxSecs int64 `json:"timebox_secs"`
|
||||||
|
|
||||||
|
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DriftView is the active-only drift projection.
|
||||||
|
type DriftView struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoachView struct {
|
type CoachView struct {
|
||||||
@@ -121,6 +152,7 @@ type State struct {
|
|||||||
Commitment *CommitmentView `json:"commitment"`
|
Commitment *CommitmentView `json:"commitment"`
|
||||||
Evidence *EvidenceView `json:"evidence"`
|
Evidence *EvidenceView `json:"evidence"`
|
||||||
Coach *CoachView `json:"coach,omitempty"`
|
Coach *CoachView `json:"coach,omitempty"`
|
||||||
|
Drift *DriftView `json:"drift,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// New loads any persisted snapshot, prunes stale session logs, and rebuilds
|
// New loads any persisted snapshot, prunes stale session logs, and rebuilds
|
||||||
@@ -148,6 +180,11 @@ func New(snapshotPath string) (*Controller, error) {
|
|||||||
}
|
}
|
||||||
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
|
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
|
||||||
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
|
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
|
||||||
|
c.allowedClasses = s.AllowedWindowClasses
|
||||||
|
// Drift state is not persisted: recompute fresh after restart to avoid
|
||||||
|
// stale interrupts. This also initializes judgedClasses (the pipeline
|
||||||
|
// writes to it) so a restored Active session never panics on a nil map.
|
||||||
|
c.resetDriftLocked()
|
||||||
c.replayStats(s.SessionID)
|
c.replayStats(s.SessionID)
|
||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
@@ -225,10 +262,18 @@ func (c *Controller) stateLocked() State {
|
|||||||
NextAction: c.coachProposal.NextAction,
|
NextAction: c.coachProposal.NextAction,
|
||||||
SuccessCondition: c.coachProposal.SuccessCondition,
|
SuccessCondition: c.coachProposal.SuccessCondition,
|
||||||
TimeboxSecs: c.coachProposal.TimeboxSecs,
|
TimeboxSecs: c.coachProposal.TimeboxSecs,
|
||||||
|
AllowedWindowClasses: c.coachProposal.AllowedWindowClasses,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
st.Coach = cv
|
st.Coach = cv
|
||||||
}
|
}
|
||||||
|
if c.runtimeState == domain.RuntimeActive {
|
||||||
|
status := c.driftStatus
|
||||||
|
if status == "" {
|
||||||
|
status = driftIdle
|
||||||
|
}
|
||||||
|
st.Drift = &DriftView{Status: status, Reason: c.driftReason}
|
||||||
|
}
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +298,7 @@ func (c *Controller) persistLocked() error {
|
|||||||
if c.stats != nil {
|
if c.stats != nil {
|
||||||
snap.SessionID = c.stats.SessionID
|
snap.SessionID = c.stats.SessionID
|
||||||
}
|
}
|
||||||
|
snap.AllowedWindowClasses = c.allowedClasses
|
||||||
return store.Save(c.snapshotPath, snap)
|
return store.Save(c.snapshotPath, snap)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +332,71 @@ func (c *Controller) resetCoachLocked() {
|
|||||||
c.coachGen++
|
c.coachGen++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AllowedClassesForTest exposes the session allowed classes for tests.
|
||||||
|
func (c *Controller) AllowedClassesForTest() []string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return append([]string(nil), c.allowedClasses...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
|
||||||
|
// working; unmatched windows simply stay idle.
|
||||||
|
func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.judge = j
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
|
||||||
|
func (c *Controller) resetDriftLocked() {
|
||||||
|
c.driftStatus = driftIdle
|
||||||
|
c.driftReason = ""
|
||||||
|
c.driftGen++
|
||||||
|
c.lastJudgedAt = time.Time{}
|
||||||
|
c.judgedClasses = map[string]ai.Verdict{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnTask appends the current window class to the session allowed-context, clears
|
||||||
|
// drift, drops any cached verdict for that class, and persists. The class now
|
||||||
|
// matches locally and is never re-judged.
|
||||||
|
func (c *Controller) OnTask() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.runtimeState != domain.RuntimeActive {
|
||||||
|
return ErrNotActive
|
||||||
|
}
|
||||||
|
var class string
|
||||||
|
if c.stats != nil {
|
||||||
|
class = c.stats.Current.Class
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(class) != "" {
|
||||||
|
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
||||||
|
if !evidence.MatchesAllowed(ac, class, "") {
|
||||||
|
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class))
|
||||||
|
}
|
||||||
|
delete(c.judgedClasses, class)
|
||||||
|
}
|
||||||
|
c.driftStatus = driftOnTask
|
||||||
|
c.driftReason = ""
|
||||||
|
return c.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refocus clears the current drift verdict without changing allowed-context, and
|
||||||
|
// drops the cached verdict for the current class so it may be judged again later.
|
||||||
|
func (c *Controller) Refocus() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.runtimeState != domain.RuntimeActive {
|
||||||
|
return ErrNotActive
|
||||||
|
}
|
||||||
|
if c.stats != nil {
|
||||||
|
delete(c.judgedClasses, c.stats.Current.Class)
|
||||||
|
}
|
||||||
|
c.driftStatus = driftIdle
|
||||||
|
c.driftReason = ""
|
||||||
|
return c.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -351,7 +462,7 @@ func coachErrorMessage(err error) string {
|
|||||||
// StartManualCommitment validates input, activates a new commitment, mints a
|
// StartManualCommitment validates input, activates a new commitment, mints a
|
||||||
// session, seeds evidence stats from the latest window, and moves Planning ->
|
// session, seeds evidence stats from the latest window, and moves Planning ->
|
||||||
// Active.
|
// Active.
|
||||||
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration) error {
|
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
commitment, err := domain.NewManual(nextAction, successCondition, timebox)
|
commitment, err := domain.NewManual(nextAction, successCondition, timebox)
|
||||||
@@ -372,6 +483,8 @@ func (c *Controller) StartManualCommitment(nextAction, successCondition string,
|
|||||||
c.deadline = now.Add(timebox)
|
c.deadline = now.Add(timebox)
|
||||||
c.outcomePending = ""
|
c.outcomePending = ""
|
||||||
c.resetCoachLocked()
|
c.resetCoachLocked()
|
||||||
|
c.allowedClasses = append([]string(nil), allowedClasses...)
|
||||||
|
c.resetDriftLocked()
|
||||||
|
|
||||||
sessionID := "session-" + uuid.Must(uuid.NewV7()).String()
|
sessionID := "session-" + uuid.Must(uuid.NewV7()).String()
|
||||||
c.stats = &EvidenceStats{
|
c.stats = &EvidenceStats{
|
||||||
@@ -436,6 +549,8 @@ func (c *Controller) End() error {
|
|||||||
c.deadline = time.Time{}
|
c.deadline = time.Time{}
|
||||||
c.stats = nil
|
c.stats = nil
|
||||||
c.outcomePending = ""
|
c.outcomePending = ""
|
||||||
|
c.allowedClasses = nil
|
||||||
|
c.resetDriftLocked()
|
||||||
return c.persistLocked()
|
return c.persistLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,8 +594,99 @@ func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
|
|||||||
now := c.clock()
|
now := c.clock()
|
||||||
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
|
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
|
||||||
c.applyEvent(now, snap)
|
c.applyEvent(now, snap)
|
||||||
|
launch := c.evaluateDriftLocked(now, snap)
|
||||||
|
c.mu.Unlock()
|
||||||
|
if launch != nil {
|
||||||
|
go launch()
|
||||||
|
}
|
||||||
|
c.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluateDriftLocked runs the local-first drift pipeline for one observation
|
||||||
|
// and updates synchronous drift state. When an async judgment is warranted it
|
||||||
|
// returns the judging closure; the caller runs it in a goroutine after
|
||||||
|
// releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has
|
||||||
|
// already verified the runtime is Active.
|
||||||
|
func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() {
|
||||||
|
class, title := snap.Class, snap.Title
|
||||||
|
|
||||||
|
// 1. Local match: authoritative on-task, no LLM.
|
||||||
|
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
||||||
|
if evidence.MatchesAllowed(ac, class, title) {
|
||||||
|
c.driftStatus = driftOnTask
|
||||||
|
c.driftReason = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Per-class cache.
|
||||||
|
if v, ok := c.judgedClasses[class]; ok {
|
||||||
|
c.applyVerdictLocked(v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. No judge wired: never block; leave idle.
|
||||||
|
if c.judge == nil {
|
||||||
|
c.driftStatus = driftIdle
|
||||||
|
c.driftReason = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Debounce: at most one judgment per driftDebounce window.
|
||||||
|
if !c.lastJudgedAt.IsZero() && now.Sub(c.lastJudgedAt) < driftDebounce {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
prevStatus, prevReason := c.driftStatus, c.driftReason
|
||||||
|
c.driftGen++
|
||||||
|
gen := c.driftGen
|
||||||
|
c.lastJudgedAt = now
|
||||||
|
c.driftStatus = driftPending
|
||||||
|
c.driftReason = ""
|
||||||
|
judge := c.judge
|
||||||
|
commitment := ""
|
||||||
|
if c.commitment != nil {
|
||||||
|
commitment = c.commitment.NextAction + " — " + c.commitment.SuccessCondition
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), driftTimeout)
|
||||||
|
defer cancel()
|
||||||
|
v, err := judge.JudgeDrift(ctx, commitment, class, title)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if gen != c.driftGen || c.runtimeState != domain.RuntimeActive {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return // stale: a newer judgment started or the session ended
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// Degrade: never fabricate drift. Revert the optimistic pending.
|
||||||
|
if c.driftStatus == driftPending {
|
||||||
|
c.driftStatus = prevStatus
|
||||||
|
c.driftReason = prevReason
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
log.Printf("session: drift judge failed: %v", err)
|
||||||
|
c.notify()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.judgedClasses[class] = v
|
||||||
|
if c.stats != nil && c.stats.Current.Class == class {
|
||||||
|
c.applyVerdictLocked(v)
|
||||||
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
c.notify()
|
c.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
|
||||||
|
func (c *Controller) applyVerdictLocked(v ai.Verdict) {
|
||||||
|
if v.OnTask {
|
||||||
|
c.driftStatus = driftOnTask
|
||||||
|
c.driftReason = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.driftStatus = driftDrifting
|
||||||
|
c.driftReason = v.Reason
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyEvent advances stats by one observation: it credits the prior segment to
|
// applyEvent advances stats by one observation: it credits the prior segment to
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ func TestHappyPathDrivesStates(t *testing.T) {
|
|||||||
if err := c.EnterPlanning(); err != nil {
|
if err := c.EnterPlanning(); err != nil {
|
||||||
t.Fatalf("enter planning: %v", err)
|
t.Fatalf("enter planning: %v", err)
|
||||||
}
|
}
|
||||||
if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute); err != nil {
|
if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute, nil); err != nil {
|
||||||
t.Fatalf("start commitment: %v", err)
|
t.Fatalf("start commitment: %v", err)
|
||||||
}
|
}
|
||||||
st := c.State()
|
st := c.State()
|
||||||
@@ -61,7 +62,7 @@ func TestHappyPathDrivesStates(t *testing.T) {
|
|||||||
func TestStartCommitmentRejectsInvalidInput(t *testing.T) {
|
func TestStartCommitmentRejectsInvalidInput(t *testing.T) {
|
||||||
c, _ := newTestController(t)
|
c, _ := newTestController(t)
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
if err := c.StartManualCommitment("", "x", 25*time.Minute); err == nil {
|
if err := c.StartManualCommitment("", "x", 25*time.Minute, nil); err == nil {
|
||||||
t.Fatalf("empty next action should error")
|
t.Fatalf("empty next action should error")
|
||||||
}
|
}
|
||||||
if c.State().RuntimeState != domain.RuntimePlanning {
|
if c.State().RuntimeState != domain.RuntimePlanning {
|
||||||
@@ -76,7 +77,7 @@ func TestStateRestoresFromSnapshot(t *testing.T) {
|
|||||||
t.Fatalf("new: %v", err)
|
t.Fatalf("new: %v", err)
|
||||||
}
|
}
|
||||||
_ = first.EnterPlanning()
|
_ = first.EnterPlanning()
|
||||||
_ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute)
|
_ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute, nil)
|
||||||
|
|
||||||
second, err := New(path)
|
second, err := New(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -98,7 +99,7 @@ func TestRestoredCommitmentKeepsDeadline(t *testing.T) {
|
|||||||
t.Fatalf("new: %v", err)
|
t.Fatalf("new: %v", err)
|
||||||
}
|
}
|
||||||
_ = first.EnterPlanning()
|
_ = first.EnterPlanning()
|
||||||
_ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute)
|
_ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute, nil)
|
||||||
want := first.State().Commitment.DeadlineUnixSecs
|
want := first.State().Commitment.DeadlineUnixSecs
|
||||||
|
|
||||||
second, err := New(path)
|
second, err := New(path)
|
||||||
@@ -141,7 +142,7 @@ func TestAccumulatesBucketsAndSwitches(t *testing.T) {
|
|||||||
c.RecordWindow(snap("code", "antidrift")) // latest window before start
|
c.RecordWindow(snap("code", "antidrift")) // latest window before start
|
||||||
|
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
_ = c.StartManualCommitment("work", "done", 25*time.Minute) // seeds at t=1000 with code/antidrift
|
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seeds at t=1000 with code/antidrift
|
||||||
|
|
||||||
clk.advance(10 * time.Second)
|
clk.advance(10 * time.Second)
|
||||||
c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox
|
c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox
|
||||||
@@ -176,7 +177,7 @@ func TestUnavailableAccruesToReservedBucket(t *testing.T) {
|
|||||||
clk := &fakeClock{now: time.Unix(1000, 0)}
|
clk := &fakeClock{now: time.Unix(1000, 0)}
|
||||||
c.SetClock(clk.fn())
|
c.SetClock(clk.fn())
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
_ = c.StartManualCommitment("work", "done", 25*time.Minute) // seed: empty unavailable latestWindow
|
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seed: empty unavailable latestWindow
|
||||||
// latestWindow was zero-value (unavailable) at seed time.
|
// latestWindow was zero-value (unavailable) at seed time.
|
||||||
clk.advance(5 * time.Second)
|
clk.advance(5 * time.Second)
|
||||||
c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket
|
c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket
|
||||||
@@ -193,7 +194,7 @@ func TestCrashReplayRebuildsStats(t *testing.T) {
|
|||||||
first.SetClock(clk.fn())
|
first.SetClock(clk.fn())
|
||||||
first.RecordWindow(snap("code", "antidrift"))
|
first.RecordWindow(snap("code", "antidrift"))
|
||||||
_ = first.EnterPlanning()
|
_ = first.EnterPlanning()
|
||||||
_ = first.StartManualCommitment("work", "done", 25*time.Minute)
|
_ = first.StartManualCommitment("work", "done", 25*time.Minute, nil)
|
||||||
clk.advance(15 * time.Second)
|
clk.advance(15 * time.Second)
|
||||||
first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch
|
first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch
|
||||||
|
|
||||||
@@ -222,7 +223,7 @@ func TestEndWritesAuditSummary(t *testing.T) {
|
|||||||
c.SetClock(clk.fn())
|
c.SetClock(clk.fn())
|
||||||
c.RecordWindow(snap("code", "antidrift"))
|
c.RecordWindow(snap("code", "antidrift"))
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
_ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute)
|
_ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil)
|
||||||
clk.advance(30 * time.Second)
|
clk.advance(30 * time.Second)
|
||||||
c.RecordWindow(snap("firefox", "docs"))
|
c.RecordWindow(snap("firefox", "docs"))
|
||||||
clk.advance(10 * time.Second)
|
clk.advance(10 * time.Second)
|
||||||
@@ -339,13 +340,209 @@ func TestRequestCoachStaleResultDiscarded(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOnTaskRequiresActive(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
if err := c.OnTask(); !errors.Is(err, ErrNotActive) {
|
||||||
|
t.Fatalf("OnTask outside Active should error, got %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Refocus(); !errors.Is(err, ErrNotActive) {
|
||||||
|
t.Fatalf("Refocus outside Active should error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartCommitmentPersistsAllowedClasses(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
|
first, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new: %v", err)
|
||||||
|
}
|
||||||
|
_ = first.EnterPlanning()
|
||||||
|
if err := first.StartManualCommitment("a", "b", 25*time.Minute, []string{"code"}); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
second, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload: %v", err)
|
||||||
|
}
|
||||||
|
if got := second.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
|
||||||
|
t.Fatalf("allowed classes not restored: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnTaskAppendsCurrentClass(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
_ = c.EnterPlanning()
|
||||||
|
_ = c.StartManualCommitment("a", "b", 25*time.Minute, nil)
|
||||||
|
c.RecordWindow(evidence.WindowSnapshot{Class: "slack", Title: "chat", Health: evidence.EvidenceHealth{Available: true}})
|
||||||
|
if err := c.OnTask(); err != nil {
|
||||||
|
t.Fatalf("ontask: %v", err)
|
||||||
|
}
|
||||||
|
got := c.AllowedClassesForTest()
|
||||||
|
if len(got) != 1 || got[0] != "slack" {
|
||||||
|
t.Fatalf("OnTask should append current class, got %#v", got)
|
||||||
|
}
|
||||||
|
st := c.State()
|
||||||
|
if st.Drift == nil || st.Drift.Status != "ontask" {
|
||||||
|
t.Fatalf("OnTask should set drift ontask, got %+v", st.Drift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeJudge struct {
|
||||||
|
verdict ai.Verdict
|
||||||
|
err error
|
||||||
|
gate chan struct{}
|
||||||
|
calls int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
|
||||||
|
atomic.AddInt32(&f.calls, 1)
|
||||||
|
if f.gate != nil {
|
||||||
|
<-f.gate
|
||||||
|
}
|
||||||
|
return f.verdict, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitDriftStatus(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.Drift != nil && st.Drift.Status == want {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("drift status never reached %q (last: %+v)", want, c.State().Drift)
|
||||||
|
return State{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startActive(t *testing.T, c *Controller, allowed []string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("planning: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, allowed); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func obs(class, title string) evidence.WindowSnapshot {
|
||||||
|
return evidence.WindowSnapshot{Class: class, Title: title, Health: evidence.EvidenceHealth{Available: true}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalMatchIsOnTaskWithoutJudge(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "should not be called"}}
|
||||||
|
c.SetDriftJudge(fj)
|
||||||
|
startActive(t, c, []string{"code"})
|
||||||
|
c.RecordWindow(obs("Code", "main.go"))
|
||||||
|
st := c.State()
|
||||||
|
if st.Drift == nil || st.Drift.Status != "ontask" {
|
||||||
|
t.Fatalf("local match should be on-task, got %+v", st.Drift)
|
||||||
|
}
|
||||||
|
if atomic.LoadInt32(&fj.calls) != 0 {
|
||||||
|
t.Fatalf("judge must not be called on a local match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmatchedWindowIsJudgedDrifting(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "YouTube is off-task"}})
|
||||||
|
startActive(t, c, []string{"code"})
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
|
st := waitDriftStatus(t, c, "drifting")
|
||||||
|
if st.Drift.Reason != "YouTube is off-task" {
|
||||||
|
t.Fatalf("reason not surfaced: %+v", st.Drift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPerClassCacheAvoidsSecondJudge(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
now := time.Now()
|
||||||
|
c.SetClock(func() time.Time { return now })
|
||||||
|
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}
|
||||||
|
c.SetDriftJudge(fj)
|
||||||
|
startActive(t, c, []string{"code"})
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
|
waitDriftStatus(t, c, "drifting")
|
||||||
|
// Advance past the debounce window so a second judge call would be allowed:
|
||||||
|
// this isolates the per-class cache as the only reason the judge is skipped.
|
||||||
|
now = now.Add(2 * driftDebounce)
|
||||||
|
c.RecordWindow(obs("firefox", "Reddit")) // same class, should hit cache
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if got := atomic.LoadInt32(&fj.calls); got != 1 {
|
||||||
|
t.Fatalf("cached class should not re-judge, calls=%d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebounceLimitsJudgeCalls(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
now := time.Now()
|
||||||
|
c.SetClock(func() time.Time { return now })
|
||||||
|
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}, gate: make(chan struct{})}
|
||||||
|
c.SetDriftJudge(fj)
|
||||||
|
startActive(t, c, []string{"code"})
|
||||||
|
c.RecordWindow(obs("firefox", "a")) // launches one (blocked on gate)
|
||||||
|
c.RecordWindow(obs("chrome", "b")) // within debounce window -> suppressed
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if got := atomic.LoadInt32(&fj.calls); got != 1 {
|
||||||
|
t.Fatalf("debounce should allow one judge call, calls=%d", got)
|
||||||
|
}
|
||||||
|
close(fj.gate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaleJudgmentDiscardedAfterEnd(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}, gate: make(chan struct{})}
|
||||||
|
c.SetDriftJudge(fj)
|
||||||
|
startActive(t, c, []string{"code"})
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube")) // launches, blocks on gate
|
||||||
|
waitDriftStatus(t, c, "pending")
|
||||||
|
_ = c.Complete()
|
||||||
|
_ = c.End()
|
||||||
|
close(fj.gate) // release stale goroutine; must be discarded
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if c.State().RuntimeState != domain.RuntimeLocked {
|
||||||
|
t.Fatalf("should be Locked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNilJudgeLeavesUnmatchedIdle(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
startActive(t, c, []string{"code"})
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
|
st := c.State()
|
||||||
|
if st.Drift == nil || st.Drift.Status != "idle" {
|
||||||
|
t.Fatalf("nil judge should leave unmatched idle, got %+v", st.Drift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoredActiveSessionCanBeJudged(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
|
first, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new: %v", err)
|
||||||
|
}
|
||||||
|
_ = first.EnterPlanning()
|
||||||
|
if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
second, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload: %v", err)
|
||||||
|
}
|
||||||
|
second.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||||
|
second.RecordWindow(obs("firefox", "YouTube")) // must not panic on nil judgedClasses map
|
||||||
|
waitDriftStatus(t, second, "drifting")
|
||||||
|
}
|
||||||
|
|
||||||
func TestLeavingPlanningClearsCoach(t *testing.T) {
|
func TestLeavingPlanningClearsCoach(t *testing.T) {
|
||||||
c, _ := newTestController(t)
|
c, _ := newTestController(t)
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
|
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
|
||||||
_ = c.RequestCoach("x")
|
_ = c.RequestCoach("x")
|
||||||
waitCoachStatus(t, c, "ready")
|
waitCoachStatus(t, c, "ready")
|
||||||
if err := c.StartManualCommitment("a", "b", 25*time.Minute); err != nil {
|
if err := c.StartManualCommitment("a", "b", 25*time.Minute, nil); err != nil {
|
||||||
t.Fatalf("start: %v", err)
|
t.Fatalf("start: %v", err)
|
||||||
}
|
}
|
||||||
if c.State().Coach != nil {
|
if c.State().Coach != nil {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type Snapshot struct {
|
|||||||
DeadlineUnixSecs int64 `json:"deadline_unix_secs,omitempty"`
|
DeadlineUnixSecs int64 `json:"deadline_unix_secs,omitempty"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
OutcomePending string `json:"outcome_pending,omitempty"`
|
OutcomePending string `json:"outcome_pending,omitempty"`
|
||||||
|
|
||||||
|
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultPath returns ~/.antidrift/state.json.
|
// DefaultPath returns ~/.antidrift/state.json.
|
||||||
|
|||||||
@@ -28,6 +28,12 @@
|
|||||||
.buckets { list-style: none; padding: 0; margin: 10px 0 0; font-size: 13px; color: #9aa0b4; }
|
.buckets { list-style: none; padding: 0; margin: 10px 0 0; font-size: 13px; color: #9aa0b4; }
|
||||||
.buckets li { display: flex; justify-content: space-between; padding: 2px 0; font-variant-numeric: tabular-nums; }
|
.buckets li { display: flex; justify-content: space-between; padding: 2px 0; font-variant-numeric: tabular-nums; }
|
||||||
.switches { font-size: 13px; color: #7a8095; margin-top: 8px; }
|
.switches { font-size: 13px; color: #7a8095; margin-top: 8px; }
|
||||||
|
.drift { background: #2a1d22; border: 1px solid #5a2d39; border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
|
||||||
|
.drift-title { font-weight: 700; color: #f0a3b1; }
|
||||||
|
.drift-reason { color: #e6c0c8; margin: 4px 0 10px; }
|
||||||
|
.drift-actions button { margin: 0 8px 0 0; padding: 8px 14px; }
|
||||||
|
.drift-actions button.secondary { background: #2d3242; color: #cdd2e0; }
|
||||||
|
.drift-hint { font-size: 12px; color: #7a8095; margin-bottom: 10px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -71,6 +77,29 @@ function evidenceBlock(ev) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateActiveDrift(drift) {
|
||||||
|
const el = document.getElementById('drift');
|
||||||
|
if (!el) return;
|
||||||
|
const status = drift && drift.status;
|
||||||
|
if (status === 'drifting') {
|
||||||
|
el.innerHTML = `<div class="drift">
|
||||||
|
<div class="drift-title">⚠ Possible drift</div>
|
||||||
|
<div class="drift-reason">${drift.reason || ''}</div>
|
||||||
|
<div class="drift-actions">
|
||||||
|
<button id="refocus" type="button">Back to task</button>
|
||||||
|
<button id="ontask" type="button" class="secondary">This is on task</button>
|
||||||
|
<button id="enddrift" type="button" class="secondary">End session</button>
|
||||||
|
</div></div>`;
|
||||||
|
document.getElementById('refocus').onclick = () => post('/refocus');
|
||||||
|
document.getElementById('ontask').onclick = () => post('/ontask');
|
||||||
|
document.getElementById('enddrift').onclick = () => post('/complete');
|
||||||
|
} else if (status === 'pending') {
|
||||||
|
el.innerHTML = `<div class="drift-hint">checking focus…</div>`;
|
||||||
|
} else {
|
||||||
|
el.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updatePlanningCoach(coach) {
|
function updatePlanningCoach(coach) {
|
||||||
const statusEl = document.getElementById('coachStatus');
|
const statusEl = document.getElementById('coachStatus');
|
||||||
if (!statusEl) return;
|
if (!statusEl) return;
|
||||||
@@ -90,6 +119,10 @@ function updatePlanningCoach(coach) {
|
|||||||
if (sc && !sc.value.trim()) sc.value = coach.proposal.success_condition || '';
|
if (sc && !sc.value.trim()) sc.value = coach.proposal.success_condition || '';
|
||||||
if (mins && coach.proposal.timebox_secs) mins.value = Math.round(coach.proposal.timebox_secs / 60);
|
if (mins && coach.proposal.timebox_secs) mins.value = Math.round(coach.proposal.timebox_secs / 60);
|
||||||
if (start) start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0);
|
if (start) start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0);
|
||||||
|
const apps = document.getElementById('apps');
|
||||||
|
if (apps && !apps.value.trim() && Array.isArray(coach.proposal.allowed_window_classes)) {
|
||||||
|
apps.value = coach.proposal.allowed_window_classes.join(', ');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +132,10 @@ function render(state) {
|
|||||||
updatePlanningCoach(state.coach); // partial update only
|
updatePlanningCoach(state.coach); // partial update only
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (rs === 'active' && renderedState === 'active') {
|
||||||
|
updateActiveDrift(state.drift);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
|
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
|
||||||
renderedState = rs;
|
renderedState = rs;
|
||||||
if (rs === 'locked') {
|
if (rs === 'locked') {
|
||||||
@@ -115,6 +152,8 @@ function render(state) {
|
|||||||
<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?">
|
||||||
<label>Minutes</label><input id="mins" type="number" min="1" value="25">
|
<label>Minutes</label><input id="mins" type="number" min="1" value="25">
|
||||||
|
<label>Allowed apps (comma-separated window classes)</label>
|
||||||
|
<input id="apps" placeholder="e.g. code, firefox">
|
||||||
<button id="start" disabled>Start commitment</button>`;
|
<button id="start" disabled>Start commitment</button>`;
|
||||||
const na = document.getElementById('na'), sc = document.getElementById('sc'),
|
const na = document.getElementById('na'), sc = document.getElementById('sc'),
|
||||||
mins = document.getElementById('mins'), start = document.getElementById('start');
|
mins = document.getElementById('mins'), start = document.getElementById('start');
|
||||||
@@ -124,6 +163,8 @@ function render(state) {
|
|||||||
next_action: na.value.trim(),
|
next_action: na.value.trim(),
|
||||||
success_condition: sc.value.trim(),
|
success_condition: sc.value.trim(),
|
||||||
timebox_secs: Math.round(+mins.value * 60),
|
timebox_secs: Math.round(+mins.value * 60),
|
||||||
|
allowed_window_classes: (document.getElementById('apps').value || '')
|
||||||
|
.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
});
|
});
|
||||||
document.getElementById('sharpen').onclick = () => {
|
document.getElementById('sharpen').onclick = () => {
|
||||||
const intent = document.getElementById('intent').value.trim();
|
const intent = document.getElementById('intent').value.trim();
|
||||||
@@ -133,6 +174,7 @@ function render(state) {
|
|||||||
} else if (rs === 'active') {
|
} else if (rs === 'active') {
|
||||||
const c = state.commitment || {};
|
const c = state.commitment || {};
|
||||||
view.innerHTML = `<span class="pill">Active</span>
|
view.innerHTML = `<span class="pill">Active</span>
|
||||||
|
<div id="drift"></div>
|
||||||
<div class="timer" id="t">--:--</div>
|
<div class="timer" id="t">--:--</div>
|
||||||
<div class="action">${c.next_action || ''}</div>
|
<div class="action">${c.next_action || ''}</div>
|
||||||
<p class="meta">Done when: ${c.success_condition || ''}</p>
|
<p class="meta">Done when: ${c.success_condition || ''}</p>
|
||||||
@@ -143,6 +185,7 @@ function render(state) {
|
|||||||
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
|
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
|
||||||
tick();
|
tick();
|
||||||
countdownTimer = setInterval(tick, 250);
|
countdownTimer = setInterval(tick, 250);
|
||||||
|
updateActiveDrift(state.drift);
|
||||||
} else if (rs === 'review') {
|
} else if (rs === 'review') {
|
||||||
const c = state.commitment || {};
|
const c = state.commitment || {};
|
||||||
view.innerHTML = `<span class="pill">Review</span>
|
view.innerHTML = `<span class="pill">Review</span>
|
||||||
|
|||||||
+12
-1
@@ -50,6 +50,8 @@ func (s *Server) Router() *gin.Engine {
|
|||||||
r.POST("/commitment", s.handleCommitment)
|
r.POST("/commitment", s.handleCommitment)
|
||||||
r.POST("/complete", s.handleComplete)
|
r.POST("/complete", s.handleComplete)
|
||||||
r.POST("/end", s.handleEnd)
|
r.POST("/end", s.handleEnd)
|
||||||
|
r.POST("/refocus", s.handleRefocus)
|
||||||
|
r.POST("/ontask", s.handleOnTask)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +102,7 @@ type commitmentRequest struct {
|
|||||||
NextAction string `json:"next_action"`
|
NextAction string `json:"next_action"`
|
||||||
SuccessCondition string `json:"success_condition"`
|
SuccessCondition string `json:"success_condition"`
|
||||||
TimeboxSecs int64 `json:"timebox_secs"`
|
TimeboxSecs int64 `json:"timebox_secs"`
|
||||||
|
AllowedWindowClasses []string `json:"allowed_window_classes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleCommitment(c *gin.Context) {
|
func (s *Server) handleCommitment(c *gin.Context) {
|
||||||
@@ -108,7 +111,7 @@ func (s *Server) handleCommitment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second)
|
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.armExpiry()
|
s.armExpiry()
|
||||||
}
|
}
|
||||||
@@ -124,6 +127,14 @@ func (s *Server) handleEnd(c *gin.Context) {
|
|||||||
s.respond(c, s.ctrl.End())
|
s.respond(c, s.ctrl.End())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRefocus(c *gin.Context) {
|
||||||
|
s.respond(c, s.ctrl.Refocus())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleOnTask(c *gin.Context) {
|
||||||
|
s.respond(c, s.ctrl.OnTask())
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleEvents(c *gin.Context) {
|
func (s *Server) handleEvents(c *gin.Context) {
|
||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream")
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
|||||||
@@ -158,3 +158,34 @@ func TestCoachRouteUnavailableDegrades(t *testing.T) {
|
|||||||
t.Fatalf("want error status, got %+v", cv)
|
t.Fatalf("want error status, got %+v", cv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
r := s.Router()
|
||||||
|
if w := post(t, r, "/refocus", ""); w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("refocus outside Active: want 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := post(t, r, "/ontask", ""); w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("ontask outside Active: want 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
r := s.Router()
|
||||||
|
_ = post(t, r, "/planning", "")
|
||||||
|
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"]}`
|
||||||
|
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("commitment: want 200, got %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := s.ctrl.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
|
||||||
|
t.Fatalf("commitment did not carry allowed classes: %#v", got)
|
||||||
|
}
|
||||||
|
// Now Active: overrides succeed.
|
||||||
|
if w := post(t, r, "/ontask", ""); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("ontask while Active: want 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := post(t, r, "/refocus", ""); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("refocus while Active: want 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user