feat(continuity): wire §6.3 shared-docs staleness notice

MarkdownChanges was deleted as dead code, but the underlying spec
requirement wasn't abandoned — rebuilt it independently. Adds
continuity.ConventionsHash for AGENTS.md/CLAUDE.md/VOCAB.md, tracks a
per-session snapshot on herdr.Session, and adds
Coordinator.checkConventions (run every Monitor tick) which compares
each active session's snapshot against its project's base repo and
pushes an in-pane notice via a new herdr.ConventionsNotifier
capability when they drift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
This commit is contained in:
kami
2026-07-27 22:25:53 +04:00
parent 3fe3aee5b7
commit 0ca78243b9
7 changed files with 225 additions and 8 deletions
+26 -8
View File
@@ -708,14 +708,32 @@ cooperation):
confirmed by reading `cmd/orchestra/main.go`); an earlier draft of this confirmed by reading `cmd/orchestra/main.go`); an earlier draft of this
prompt invented that endpoint and was corrected before landing, which is prompt invented that endpoint and was corrected before landing, which is
exactly the class of bug this audit exists to catch. exactly the class of bug this audit exists to catch.
3. **Item 6 — `MarkdownChanges` deleted, not wired.** Confirmed zero 3. **Item 6 — `MarkdownChanges` deleted 2026-07-27, then §6.3 rewired from
callers anywhere (including its own tests — there were none, despite scratch 2026-07-27 (later same day).** The original `MarkdownChanges`
being listed as "believed accurate" in a prior progress.md snapshot). function had zero callers and zero tests (confirmed by grep before
Wiring it for real needs a design for what "adjacent task" means and deleting), so it was removed rather than half-wired, per this doc's own
where the notice surfaces (brief? a new event type?), which is a real "delete and record the deviation" option. A real implementation of §6.3
feature, not a wiring fix — AUDIT.md explicitly allows "delete it and ("on update, the orchestra injects a notice to agents whose current task
record the deviation" as the alternative to half-implementing that. Taking is adjacent") was built separately, decoupled from the deleted function:
that option rather than bolting on an undesigned notification path. `continuity.ConventionsHash(root)` hashes whichever of
`AGENTS.md`/`CLAUDE.md`/`VOCAB.md` exist at a path; `herdr.Session` gained
`ConventionsHash`, snapshotted from the fresh worktree at
`Coordinator.Start`; a new `Coordinator.checkConventions`, run every
`Monitor` tick, recomputes the hash of the *project's base repo* (via
`WorktreeSpec.Spec`, i.e. "adjacent" = same project) for every leased
session and compares it against that session's stored snapshot — a
mismatch means the shared docs were updated upstream since this session
started. On mismatch it calls a new optional `herdr.ConventionsNotifier`
capability (`CLIAdapter.NotifyConventionsChanged`, an `agent.prompt` telling
the agent to re-read the docs) and updates the stored hash so the notice
fires once per drift, not every tick. Deliberately does not touch the
brief or add a new event type — the notice is a direct in-pane nudge, not
a projection, matching the spec's phrasing ("injects a notice to agents"),
so there was no event-schema question to resolve first. Covered by
`TestConventionsDriftNotifiesActiveSession`
(internal/orchestrator/rotation_test.go): asserts zero notifications while
the base repo's docs are unchanged, then a notification once they diverge.
`go build`/`go vet`/`go test ./...` all pass.
**Phase 4 item 2 — closed 2026-07-27.** The remaining gap after B5 was not **Phase 4 item 2 — closed 2026-07-27.** The remaining gap after B5 was not
"which harness" (Release was always harness-agnostic) — it was that *nothing, "which harness" (Release was always harness-agnostic) — it was that *nothing,
+25
View File
@@ -50,6 +50,31 @@ func TaskFileHash(root string) (string, error) {
return hex.EncodeToString(sum[:]), nil return hex.EncodeToString(sum[:]), nil
} }
// ConventionsFiles are the §6.3 shared docs ("Shared *.md ... all agents
// contribute") whose staleness the orchestra is responsible for tracking —
// never the agent, which only sees its own cached copy.
var ConventionsFiles = []string{"AGENTS.md", "CLAUDE.md", "VOCAB.md"}
// ConventionsHash hashes the concatenation of whichever ConventionsFiles
// exist at root, in that fixed order, so the result changes iff any of their
// contents change (a file appearing/disappearing also changes it, since a
// length-prefixed marker precedes each file's bytes). A repo with none of
// these files hashes to a stable, comparable empty-set value rather than
// erroring — §6.3 is optional infrastructure, not every project uses it.
func ConventionsHash(root string) (string, error) {
h := sha256.New()
for _, name := range ConventionsFiles {
b, err := os.ReadFile(filepath.Join(root, name))
if err != nil {
fmt.Fprintf(h, "%s:0\n", name)
continue
}
fmt.Fprintf(h, "%s:%d\n", name, len(b))
h.Write(b)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
type Dirty struct { type Dirty struct {
Path string `json:"path"` Path string `json:"path"`
SHA256 string `json:"sha256"` SHA256 string `json:"sha256"`
+16
View File
@@ -135,6 +135,22 @@ type HandoffRequester interface {
func (a CLIAdapter) RequestHandoff(ctx context.Context, s Session) error { func (a CLIAdapter) RequestHandoff(ctx context.Context, s Session) error {
return a.Client.Prompt(ctx, s.PaneID, handoffPrompt, time.Minute) return a.Client.Prompt(ctx, s.PaneID, handoffPrompt, time.Minute)
} }
// conventionsPrompt is §6.3's "orchestra injects a notice to agents whose
// current task is adjacent" — staleness is tracked here, not by trusting the
// agent's cached view of the shared docs.
const conventionsPrompt = `Notice from Orchestra: the shared project conventions (AGENTS.md / CLAUDE.md / VOCAB.md) have been updated since you started this task. Re-read them now before continuing, in case something you're relying on has changed.`
// ConventionsNotifier is the optional capability rotate()'s convention-drift
// check uses; adapters without a live pane (tests, non-interactive harnesses)
// can omit it.
type ConventionsNotifier interface {
NotifyConventionsChanged(context.Context, Session) error
}
func (a CLIAdapter) NotifyConventionsChanged(ctx context.Context, s Session) error {
return a.Client.Prompt(ctx, s.PaneID, conventionsPrompt, time.Minute)
}
// Release reads the §6.1 handoff the agent wrote to HandoffFile at the // Release reads the §6.1 handoff the agent wrote to HandoffFile at the
// worktree root, validates its schema and anchor against the worktree's real // worktree root, validates its schema and anchor against the worktree's real
// HEAD, uploads it to CAS, and only then releases herdr's claim on the pane // HEAD, uploads it to CAS, and only then releases herdr's claim on the pane
+6
View File
@@ -157,6 +157,12 @@ type Session struct {
// its §6.1 handoff (HandoffFile) — avoids re-sending the same prompt // its §6.1 handoff (HandoffFile) — avoids re-sending the same prompt
// every tick while Release keeps waiting for the file to appear. // every tick while Release keeps waiting for the file to appear.
HandoffRequested bool `json:"handoff_requested,omitempty"` HandoffRequested bool `json:"handoff_requested,omitempty"`
// ConventionsHash is continuity.ConventionsHash of the project's shared
// *.md docs (AGENTS.md/CLAUDE.md/VOCAB.md) at the time this session was
// last notified of (or started with) their content — §6.3's staleness
// tracking lives at the orchestra layer, never trusted from the agent's
// cached view.
ConventionsHash string `json:"conventions_hash,omitempty"`
} }
func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error { func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error {
+61
View File
@@ -382,6 +382,7 @@ func (c *Coordinator) Monitor(ctx context.Context, hard float64, interval time.D
case <-t.C: case <-t.C:
c.refreshSessionHealth(ctx) c.refreshSessionHealth(ctx)
c.cleanupCompleted(ctx) c.cleanupCompleted(ctx)
c.checkConventions(ctx)
expired, err := c.expire(ctx) expired, err := c.expire(ctx)
c.setMonitorHealth(err, len(expired)) c.setMonitorHealth(err, len(expired))
if err != nil { if err != nil {
@@ -420,6 +421,61 @@ func (c *Coordinator) cleanupCompleted(ctx context.Context) {
} }
} }
// checkConventions is §6.3: "on update, the orchestra injects a notice to
// agents whose current task is adjacent" — adjacency here is "same project's
// base repo," and staleness is tracked by comparing each session's own
// last-known continuity.ConventionsHash against the base repo's current one,
// never by trusting the agent to notice on its own.
func (c *Coordinator) checkConventions(ctx context.Context) {
spec, ok := c.Worktrees.(WorktreeSpec)
if !ok {
return
}
c.loadSessions()
c.mu.Lock()
sessions := make(map[string]herdr.Session, len(c.sessions))
for id, s := range c.sessions {
sessions[id] = s
}
c.mu.Unlock()
changed := false
for taskID, session := range sessions {
t, ok := c.Store.Task(taskID)
if !ok || t.State != domain.StateLeased {
continue
}
repo, _, valid := spec.Spec(t)
if !valid {
continue
}
hash, err := continuity.ConventionsHash(repo)
if err != nil || hash == session.ConventionsHash {
continue
}
a, err := c.adapterFor(taskID, session)
if err != nil {
continue
}
notifier, ok := a.(herdr.ConventionsNotifier)
if !ok {
continue
}
if err := notifier.NotifyConventionsChanged(ctx, session); err != nil {
continue
}
session.ConventionsHash = hash
c.mu.Lock()
c.sessions[taskID] = session
c.mu.Unlock()
changed = true
}
if changed {
c.mu.Lock()
_ = c.saveSessionsLocked()
c.mu.Unlock()
}
}
func (c *Coordinator) expire(ctx context.Context) ([]domain.Event, error) { func (c *Coordinator) expire(ctx context.Context) ([]domain.Event, error) {
// pane.exited is the low-latency path; lease expiry below remains the // pane.exited is the low-latency path; lease expiry below remains the
// authoritative backstop when herdr misses an exit notification. // authoritative backstop when herdr misses an exit notification.
@@ -610,6 +666,11 @@ func (c *Coordinator) Start(ctx context.Context, e domain.Event) error {
} }
s.HerdrID = p.HarnessID s.HerdrID = p.HarnessID
s.TaskFileSHA = taskFileSHA s.TaskFileSHA = taskFileSHA
// Best-effort, same caveat as taskFileSHA above: only meaningful for a
// worktree this process can read locally. Snapshots the shared-docs
// state this session starts trusting; checkConventions notices drift
// from here, not from whatever the agent's own cached view is (§6.3).
s.ConventionsHash, _ = continuity.ConventionsHash(w)
c.mu.Lock() c.mu.Lock()
if c.sessions == nil { if c.sessions == nil {
c.sessions = map[string]herdr.Session{} c.sessions = map[string]herdr.Session{}
+73
View File
@@ -476,3 +476,76 @@ func TestRotationRequestsHandoffBeforeReleasing(t *testing.T) {
t.Fatal("rotate never called Release once the handoff file appeared") t.Fatal("rotate never called Release once the handoff file appeared")
} }
} }
type specWorktrees struct{ wtPath, repoPath string }
func (w specWorktrees) Create(context.Context, domain.Task) (string, error) { return w.wtPath, nil }
func (w specWorktrees) Spec(domain.Task) (string, string, bool) { return w.repoPath, "", true }
type conventionsAdapter struct {
fakeAdapter
notifications int
}
func (a *conventionsAdapter) NotifyConventionsChanged(context.Context, herdr.Session) error {
a.notifications++
return nil
}
// TestConventionsDriftNotifiesActiveSession guards §6.3's wiring: "on
// update, the orchestra injects a notice to agents whose current task is
// adjacent" — never left to the agent's own cached view. A session must not
// be notified while the base repo's shared docs match what it started with,
// and must be notified once they diverge.
func TestConventionsDriftNotifiesActiveSession(t *testing.T) {
repo := t.TempDir()
worktree := t.TempDir()
if err := os.WriteFile(repo+"/AGENTS.md", []byte("v1"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(worktree+"/AGENTS.md", []byte("v1"), 0644); err != nil {
t.Fatal(err)
}
s, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: "t1", Surface: string(authz.System), Payload: mustJSON(map[string]any{
"source": "jsonl", "external_id": "1", "project": "p",
})}); err != nil {
t.Fatal(err)
}
task := s.Tasks()[0]
a := &conventionsAdapter{fakeAdapter: fakeAdapter{occupancy: 0}}
c := &orchestrator.Coordinator{Store: s, Worktrees: specWorktrees{wtPath: worktree, repoPath: repo}, Adapters: adapters{a}, StatePath: t.TempDir() + "/sessions.json"}
leaseEvt, err := s.Lease(task.ID, "h1", time.Minute)
if err != nil {
t.Fatal(err)
}
if err := c.Start(context.Background(), leaseEvt); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go c.Monitor(ctx, .8, time.Millisecond)
time.Sleep(50 * time.Millisecond)
if a.notifications != 0 {
t.Fatalf("notified with no actual drift: notifications=%d", a.notifications)
}
if err := os.WriteFile(repo+"/AGENTS.md", []byte("v2"), 0644); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) && a.notifications == 0 {
time.Sleep(time.Millisecond)
}
if a.notifications == 0 {
t.Fatal("session was never notified of the conventions-doc update")
}
}
+18
View File
@@ -137,6 +137,24 @@ Fixed so far:
half-wired, per AUDIT.md's explicit "delete and record the deviation" half-wired, per AUDIT.md's explicit "delete and record the deviation"
option. New tests: `TestReleaseScratchCommitsDirtyFilesBeforeUpload`, option. New tests: `TestReleaseScratchCommitsDirtyFilesBeforeUpload`,
`TestReleaseRefusesOnStaleDirtyFile` (internal/herdr/adapter_test.go). `TestReleaseRefusesOnStaleDirtyFile` (internal/herdr/adapter_test.go).
**§6.3 rewired for real, 2026-07-27 (later same day):** the deleted
`MarkdownChanges` above was zero-caller dead code, but the underlying spec
requirement ("on update, the orchestra injects a notice to agents whose
current task is adjacent") wasn't abandoned — rebuilt independently.
`continuity.ConventionsHash(root)` hashes whichever of
`AGENTS.md`/`CLAUDE.md`/`VOCAB.md` exist at a path; `herdr.Session` gained
`ConventionsHash`, snapshotted from the fresh worktree at
`Coordinator.Start`; a new `Coordinator.checkConventions`, run every
`Monitor` tick, recomputes the hash of the project's *base repo* (via
`WorktreeSpec.Spec` — "adjacent" = same project) for every leased session
and compares it against that session's stored snapshot. A mismatch calls a
new optional `herdr.ConventionsNotifier` capability
(`CLIAdapter.NotifyConventionsChanged`, an in-pane `agent.prompt` telling
the agent to re-read the docs) and updates the stored hash so the notice
fires once per drift, not every tick. Covered by
`TestConventionsDriftNotifiesActiveSession`
(internal/orchestrator/rotation_test.go): asserts no notification while
the base repo is unchanged, then one once it diverges.
**Was still open:** Phase 4 item 2 — nothing drove *any* harness to write **Was still open:** Phase 4 item 2 — nothing drove *any* harness to write
`.orchestra-handoff.json`, since Release only validated a file whose `.orchestra-handoff.json`, since Release only validated a file whose
existence was never solicited. **Closed 2026-07-27:** `rotate()` now checks existence was never solicited. **Closed 2026-07-27:** `rotate()` now checks