Skip to content

feat: agent identity & attribution foundation#222

Merged
miles-kt-inkeep merged 55 commits intomainfrom
worktree-agent-identity-worldmodel
Apr 22, 2026
Merged

feat: agent identity & attribution foundation#222
miles-kt-inkeep merged 55 commits intomainfrom
worktree-agent-identity-worldmodel

Conversation

@nick-inkeep
Copy link
Copy Markdown
Contributor

@nick-inkeep nick-inkeep commented Apr 20, 2026

Summary

Agent identity & attribution foundation for Open Knowledge. Establishes a coherent (principal, agent_session) actor model at the CRDT origin layer and carries it through every downstream attribution surface — per-session undo, presence, timeline, shadow-repo commits, and main-git save-version. Unblocks V0-14 (per-agent undo) and closes the attribution gaps across 15 mutating POST handlers.

Also merges origin/main (PR #254 "Open in Agent Desktop" + the 2026-04-21-shadow-repo-single-mode spec) with an architectural reconciliation — the shadow-single-mode direction wins for the shadow-repo naming + layout; the identity foundation layers on top.

Implements spec at specs/2026-04-18-agent-identity-attribution-foundation/SPEC.md via 27 user stories (US-001 through US-030) + 3 docs commits + 2 review-fix commits + merge.


Key decisions

D2 — Per-session LocalTransactionOrigin objects (F1)

Each agent session creates a deep-frozen origin at session birth:

session.origin = Object.freeze({
  source: 'local',
  context: Object.freeze({
    origin: 'agent-write', paired: true,
    session_id: connectionId, agent_type, principal
  }),
});

Replaces the shared AGENT_WRITE_ORIGIN module-level constant. Identity-unique per session; matched by Set.has(origin) in Y.UndoManager.trackedOrigins. Flows through to observer short-circuits (via isPairedWriteOrigin(origin) === context.paired === true, structural not identity).

D3/D25 — Per-session Y.UndoManager

new Y.UndoManager([ytext, metaMap, flashMap], {
  trackedOrigins: new Set([session.origin]),
  captureTimeout: 500,
  captureTransaction: tr => tr.origin !== session.undoOrigin,
  ignoreRemoteMapChanges: true,
});

Scopes across Y.Text + Y.Map('metadata') + Y.Map('agent-flash') so one agent transaction touching all three is ONE undo step. ignoreRemoteMapChanges: true prevents partial-undo across concurrent sessions.

V0-14 — applyAgentUndo (XmlFragment-authoritative)

New paired origin AGENT_UNDO_ORIGIN. Handler wraps um.undo() + updateYFragment + applyFastDiff in one doc.transact(fn, session.undoOrigin). Never rebuilds XmlFragment from Y.Text (Bug-A/Bug-D anti-pattern). Fuzzer extended with agent-undo op; fidelity PBT bridge-observer-conversion.test.ts chain E covers composition.

Attribution sweep (D42 / FR-5)

15 mutating POST handlers thread extractAgentIdentity: handleAgentWrite, handleAgentWriteMd, handleAgentPatch, handleAgentUndo, handleSaveVersion, handleRollback, handleCreatePage, handleRename, handleRenamePath, handleDeletePath, handleUploadImage, plus sync handlers. Meta-test attribution-sweep-coverage.test.ts scans the route registry and enforces the contract.

The 4 sync/* handlers (handleSyncTrigger, handleSyncSetEnabled, handleSyncAbortMerge, handleSyncResolveConflict) are control-plane orchestrators — their commits land inside SyncEngine under classified writers (git-upstream, file-system) and are already correctly attributed. Exempt per EXEMPT_HANDLERS + corrigendum on SPEC.md §FR-5.

Per-writer shadow-repo fan-out (FR-7)

commitWip emits refs/wip/<branch>/<writer-id> — one commit per distinct writer in the L2 drain, all sharing the same tree SHA. Writer-ID taxonomy (D34):

  • agent-<connectionId> — MCP session
  • principal-<UUID> — browser tab
  • file-system — file-watcher reconciled writes
  • git-upstream — HEAD-move commit imports
  • openknowledge-service — service fallback (park, rollback, etc.)

Legacy server, human-*, upstream classify as 'unknown' — allowlist-swept on first init post-upgrade.

Structured ok-actor: body (D13 / FR-8)

Every shadow-repo commit body carries:

ok-actor: {"v":1,"principal":"principal-...","agent_session":"conn-...","agent_type":"claude",
           "client_name":"claude-code","client_version":"1.5.2","label":null,
           "display_name":"Claude (a4f2)","color_seed":"claude-code","docs":["notes.md"]}

Subject-prefix action encoding: wip:, checkpoint:, reconcile:, park:, rollback:, rename:, import:.

Save-version Co-Authored-By (FR-9)

Author: Alice Smith <alice@inkeep.com>
checkpoint: feat: update section A
Co-Authored-By: Claude (a4f2) <agent-abc123@openknowledge.local>
Co-Authored-By: Cursor (9d2e) <agent-xyz789@openknowledge.local>

Rendered natively by GitHub/GitLab.

Activity log Y.Map ring-buffer (D49 / FR-11)

Y.Map('agent-effects') bounded at 50 entries per doc, oldest-by-timestamp eviction in the paired-write drain. Captures via YTextEvent.delta (Quill Delta ops — D22, not transaction.changed).

F2 session lifecycle (FR-14)

Server-side /collab/keepalive close handler reads connectionId from URL query, starts 30s grace timer, fires closeAllForAgent + clearFocus after grace. Reconnect during grace cancels the timer.

Merge reconciliation (2026-04-22)

Merge of origin/main brought in the 2026-04-21-shadow-repo-single-mode spec that took the opposite direction on naming + layout from this branch's D55/D56. User-directed resolution:

  • Keep shadow terminology (revert D55). All internal files + symbols + log prefixes retain the shadow name.
  • Shadow repo lives at <projectRoot>/.git/open-knowledge/ (single-mode; D56's <contentDir>/.open-knowledge/history/ reverts).
  • Fail fast on missing .git/ via main's ensureProjectGit (revert D45 graceful-save).
  • Adopt main's runDevShadowInit dev-plugin pipeline.
  • principal.json stays at <contentDir>/.open-knowledge/principal.json (B1 — non-git state outside .git/, gitignored).
  • D57 retainedY.Map('activity')Y.Map('agent-flash') rename is independent of the shadow-layout conflict.

Corrigendum breadcrumbs on SPEC.md §FR-5 document the merge-time adjudication per CLAUDE.md convention.

CORS allowlist (review-gate finding)

/review-local flagged the Access-Control-Allow-Origin: * header on /api/* as a CSRF vector for unauthenticated mutating endpoints. Replaced with a loopback Origin allowlist (localhost / 127.x.x.x / [::1] + "null" for packaged Electron). Non-loopback origins receive 403. Reflects the allowed Origin verbatim with Vary: Origin. isAllowedApiOrigin guards the gate.


Scope summary

Category Count
User stories shipped (US-001 through US-030) 27
New precedents (AGENTS.md #24, #25) 2
POST handlers threading identity 15
New writer taxonomy classes 5
Origin types added 2 (AGENT_UNDO_ORIGIN, PARK_SNAPSHOT_ORIGIN)
Integration tests added 3 dedicated files (session-cleanup, persistence-fan-out, per-session-um-perf) + meta-test
Docs touched AGENTS.md, PRECEDENTS.md, docs/content/internals/{service-topology,agent-write-path,lifecycle}.mdx, docs/content/guides/{timeline,github-sync,mcp-integration}.mdx, README files
Commits on branch (vs main) 84

Review setup

Standard bun run dev workflow. No new environment variables. First-run migration from legacy .git/openknowledge/ (no hyphen) to .git/open-knowledge/ (hyphenated) runs automatically in initShadowRepo.

The server now requires a project .git/ensureProjectGit fails fast on missing git. In a fresh clone, run git init in any standalone-mode project before starting the server.

To verify save-version's Co-Authored-By trailers after agent writes:

# In a dev session, trigger an agent write via MCP, then:
curl -X POST localhost:<port>/api/save-version \
  -H 'Content-Type: application/json' \
  -d '{"message":"test","principal":{"name":"Alice","email":"alice@example.com"}}'
git log -1 --pretty=%B  # should include Co-Authored-By trailers

Verification

Automated gate: bun run check15/15 tasks PASS (FULL TURBO warm replay). 1094 tests across 88 files, 0 fail.

Suite Result
@inkeep/open-knowledge-core unit 722 pass / 54 files
@inkeep/open-knowledge-server unit 836+ pass / 55 files
@inkeep/open-knowledge-app unit 660 pass / 56 files
@inkeep/open-knowledge-app integration 204 pass, 2 skip / 37 files
@inkeep/open-knowledge-app fidelity 903 pass / 32 files
@inkeep/open-knowledge-app conversion 73 pass / 1 file
CLI unit 703 pass / 60 files
Desktop unit 78 pass / 9 files
E2E (test:e2e) 15/15 Playwright files PASS (via check:full:parallel)

Local review: /review-local dispatched 17 domain reviewers. 4 findings surfaced (1 Major CORS, 2 Minor, 1 Consider). All 4 resolved in commit a718c591 + merge adjustments. Final state clean.

QA coverage (tmp/ship/qa-progress.json)

Status Count
Covered (automated tests) 18 / 25
Browser-only — deferred to post-PR manual verification 2 / 25
Needs verification (composition-covered; see below) 5 / 25

Needs human verification

  • QA-007: Subprocess reconnect after grace → new session with fresh UM stack + new refs/wip/<branch>/agent-<newUUID>. Building blocks individually covered; no single end-to-end composition test.
  • QA-022: 30-min soak for ghost-agent leak. CI runs 100-cycle NFR-5 soak (tier-1); 30-min version is ad-hoc/tier-2.
  • QA-024: Two browser tabs share one principal-<UUID> ref. Dispatch logic unit-tested (resolveWriterFromOrigin); no integration test wiring two HocuspocusProvider clients with distinct tabSessionId.
  • ux-flow QA-001: Presence-bar shows distinct avatars for two same-type agents — visual, needs browser.
  • visual QA-019: Closed session disappears from presence after 30s grace — visual, needs browser.

Merge commit — what it changed

Commit 74ea33ce merges origin/main into this branch with architectural reconciliation. Summary of rename reversals and adjustments:

  • history-*.ts files → shadow-*.ts across 8 file pairs (repo, lock, branch-gc, repo-layout in core + server)
  • Symbol reversals: initHistoryRepoinitShadowRepo, HistoryHandleShadowHandle, historyGitshadowGit, etc.
  • Log prefixes [history] / [history-migration][shadow] / [shadow-migration]
  • Shadow-repo .open-knowledge/history/.git/open-knowledge/ (main's single-mode)
  • Save-version graceful [save-version] parent-git unavailable: warn → removed; fail fast via ensureProjectGit at boot
  • installedAgentsProbe + JSON-body CORS error adopted from main

All other agent-identity work (F1 origins, per-session UM, attribution sweep, ok-actor body, save-version trailers, activity log, precedents #24/#25) retained.


Related issues

Implements the foundation described in specs/2026-04-18-agent-identity-attribution-foundation/SPEC.md. Adjacent specs on this branch path: 2026-04-21-shadow-repo-single-mode (via merge with main).


Future considerations

Deferred scope (from SPEC.md §15 Future Work)

  • Identity attestation — cryptographic verification of claimed agent identity. Blocked on MCP protocol extension or in-repo trust substrate.
  • Full headless-agent UX affordances — data model supports {null, agent_session} tuple; UI surfaces deferred until a concrete headless case ships.
  • Cross-project identity aggregation — one connectionId spans N projects. Requires cloud-tier roll-up layer.
  • v14 AttributionManager adoption — when Y.js v14 stable lands + TipTap/Hocuspocus upgrade, migrate y-lite capture to native DiffAttributionManager + IdMap.
  • Character-level per-agent attribution (Option B) — infeasible on Y.js v13.6.30 without fork.

Open questions passed through to future work

  • Q101handleApplyLinks existence + identity threading (audit during implementation; may be a no-op).
  • Q102 — Agent-type registry extensibility (hardcoded → config-driven; monitor signal).
  • Q103 — Nested agents-of-agents session model.
  • Q104 — Cross-session UndoManager stack-item validity under interleaved writes (covered by FR-17 fuzzer; empirical monitoring).
  • Q105 — Effect-diff Y.Map replication metadata scope (default: session_id + agent_type + color; revisit for richer inline rendering).

QA follow-ups

  • Add integration test composition for QA-007 (reconnect-after-grace) and QA-024 (two-tab principal ref sharing) if they surface as regressions.
  • Manual browser pass for QA-001 + QA-019 (visual presence-bar behavior) before/after any presence-layer refactor.

🤖 Generated with Claude Code

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 20, 2026

⚠️ No Changeset found

Latest commit: 4e7e985

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

nick-inkeep and others added 29 commits April 21, 2026 09:47
Consolidates existing system, user scenarios, options considered
(PRs #186/#191/#207, V0-14 spec, STORY §14), open tensions, and
invariants into a single spec-ready context artifact.

§12 addendum captures deep verification findings beyond the initial
synthesis — attribution gaps in rename/rollback/save-version, leaked
sessions on MCP subprocess exit, V0-16 cleanup residue at package
boundaries, origin-system details (seven literals, structural-only
paired check), fuzzer coverage gap for V0-14, and other edge cases.

Non-prescriptive: topology mapping only, no recommendations.
SPEC.md + evidence/ + meta/_changelog.md for specs/2026-04-18-agent-identity-attribution-foundation/.

Scope: F1 (per-session origin objects) + F2 (session lifecycle + cleanup)
foundation; derives V0-14 per-session Agent UMs, attribution completeness
sweep, shadow ref topology with classified writers (file-system /
git-upstream / git-branch-switch / openknowledge-service), main-git
save-version attribution (principal-author + Co-Authored-By trailers),
transaction-effect capture (y-lite), principal representation (stable
UUID + git config display), burst-grouping shared utility, keepalive-WS
cleanup hooks.

20 decisions locked (D1-D20) with evidence citations. 11 open questions
tracked (Q1-Q11) for iterative loop phase. Directive applied throughout:
greenfield / no deferred tech debt / architecturally best / clean codebase
/ best product UX without over-engineering.

Evidence files copied from worldmodel report:
- crdt-to-git-translation.md (end-to-end pipeline trace, Option A/B/C
  analysis, subsystem interactions)
- yjs-attribution-verification.md (C1-C8 verified against v13.6.30 and
  v14-rc; PermanentUserData + v14 AttributionManager as context)
Evidence from 3 parallel Opus investigations:
- evidence/um-mechanics.md (Y.UndoManager internals, effect-diff, doc-op edges)
- evidence/session-lifecycle.md (keepalive correlation, race, origin threading)
- evidence/shadow-git-and-sweep.md (GPG split, refs, migration, L2 drain, park)

34 decisions locked under directive (greenfield / no-deferred-debt /
architecturally best). Highlights:

- Per-session UM scope = [ytext, metaMap, activityMap], captureTimeout 500,
  trackedOrigins writes-only, captureTransaction defense, deep-freeze origin
- Effect-diff via YTextEvent.delta; Y.Map('agent-activity') storage;
  30d/500-entry eviction
- Keepalive ?connectionId= URL query + 30s cancellable WS-close grace;
  getSession in-flight promise dedup (latent race fix)
- Drop 'human-' prefix from refs (principalId already 'principal-UUID');
  delete legacy 'server' refs on first-run; git-identity sanitization
- L2 drain per-writer partition; park mutex moved; park on session refs;
  applyExternalChange attributes to file-system writer
- FR-5 covers 9 mutating endpoints + meta-test enforcement
- Product: always-new on subprocess restart; AgentFocus fires on undo +
  rollback (not rename); closed-session removes from presence; non-git mode
  shadow-only; principal.json gitignored; 30d per-writer GC TTL

All initial Q1-Q11 CLOSED. Residual Q100-Q105 (mostly P2) tracked for
implementation-time resolution.
Nick flagged D45 as pragmatism-mode (corrected) and pressure-tested
"shadow" as imprecise. Under greenfield + clean-precedents directive:

D45 rewrite: save-version is graceful, not disabled in non-git mode.
History checkpoint always lands; parent-git tag is best-effort;
transitions heal forward; no user-facing run-git-init prompt. Matches
existing code's non-fatal wrap at api-extension.ts:1877-1897.

D49 rewrite: Activity log = server-side store + CC1-broadcast
invalidation + REST fetch, not Y.Map replication. Avoids Y.Doc state
bloat; matches backlink-index precedent.

D55 (naming rename): "shadow" → "history" throughout. Aligns with
existing /api/history + UI History panel + user mental model. File
renames: shadow-repo.ts → history-repo.ts, shadow-lock.ts →
history-lock.ts, shadow-branch-gc.ts → history-branch-gc.ts,
shadow-repo-layout.ts → history-repo-layout.ts. Symbol renames:
ShadowHandle → HistoryHandle, initShadowRepo → initHistoryRepo,
etc. Log prefix [shadow] → [history].

D56 (unified state dir): .open-knowledge/ for all metadata.
Subdirs: config.yml, principal.json, history/, *.lock. Eliminates
the .openknowledge/ vs .open-knowledge/ naming drift and the
integrated/standalone bifurcation. First-run migration from legacy
locations.

52+ shadow references in SPEC replaced to history. Evidence file
shadow-git-and-sweep.md renamed to history-and-sweep.md. ASK_FIRST
stale items cleaned (all resolved by D21-D54).

Residual "shadow" in SPEC only in D55/D56 (describing the rename
transition itself).
…ap rename)

4 parallel Opus audits dispatched against D21-D56 with eng:audit skill.
All findings evaluated via eng:assess-findings at high fidelity — every
HIGH + MEDIUM finding verified against code with adversarial stance.

Applied corrections:

HIGH findings (all verified + fixed):
- D26 rationale: was factually wrong. Hocuspocus.unloadDocument DOES
  call document.destroy() (Hocuspocus.ts:580), triggering UM
  auto-destroy. Real hazard: DC blocking shouldUnloadDocument.
  Load-bearing primitive is dc.disconnect().
- FR-11: still referenced transaction.changed — contradicts D22's
  YTextEvent.delta lock. Rewrote FR-11 to align.
- D42/FR-5: missing handleSyncTrigger/handleSyncSetEnabled/
  handleSyncAbortMerge (3 mutating POST handlers at
  api-extension.ts:4012,4040,4165). Expanded enumeration 9→12.
- D35 legacy ref sweep: regex $NF=="server" missed human-server refs
  actively written by parkBranch(sessionId='server'). Switched to
  parseWriterId classification-based sweep.
- D19 refactor cost ownership: D39 framing of "one-line reorder"
  understated the parkBranch per-session refactor. D19 now explicitly
  owns the signature/loop changes; D39 owns only mutex ordering.
- D57 NEW: Y.Map('activity') → Y.Map('agent-flash'). Disambiguates
  three-way naming conflation (Y.Map flash side-channel, D49 activity
  log, D25 UM-scope activityMap). 6 code sites + D25 update.

PRAGMATISM finding:
- D39: "known tolerated microsecond-late transact" was pragmatism.
  Upgraded to transact-wrapped isolation via new PARK_SNAPSHOT_ORIGIN.
  Race eliminated, not tolerated.

MEDIUM findings applied:
- D25: ignoreRemoteMapChanges: true added (multi-agent Y.Map safety)
- D32: session context threading + STOP rule vs dc.transact(fn)
- D43: AgentFocusEntry.writeKind enum extended for undo + rollback
- D51 subsumed by D56 (directory-wide gitignore)
- D41: extend existing recordContributor (no new function)
- D55: UI "History panel" claim corrected (actual: TimelinePanel)

0 findings dismissed. 0 reopens. 0 escalations needed.

Spec state: 57 decisions locked (D1-D57). Ready for Task 11 finalize.
Fresh auditor + independent challenger on post-correction spec. Used
eng:assess-findings high-fidelity to triage.

Result: 9 CORRECT applied, 7 DISMISS with rationale, 3 ESCALATE for user.

Key corrections:
- R2-H1: §7 reverted to pre-rename filenames (describes CURRENT state,
  not target). My earlier bulk rename was overbroad.
- R2-H2: D35 switched from parseWriterId (only knows legacy taxonomy)
  to explicit allowlist sweep. Previous approach would have
  catastrophically deleted new-taxonomy refs.
- R2-H3: D57 scope expanded to 7 sites + ActivityEntry type rename.
- R2-M: FR-3 updated to Y.Map('agent-flash') + ignoreRemoteMapChanges.
- R2-M: NFR-6 locked delete-not-rewrite.
- Ch-M4: Added FR-20 for agent-type aggregation render contract.

Dismissed findings:
- Ch-H2 D6 vs journeys: re-read; journeys are Option A compatible.
- Ch-M5 "history" vs "journal": already adjudicated.
- Ch-M6 D57 prose-only alt: pragmatism under directive.
- Ch-M7 D56 git self-containment: D56 adds gitignore entry for same
  effect.
- Ch-M8 live per-line attribution: rejected per NG1.

Escalated for user:
- E1 (Ch-H1): D49 server-side+CC1+REST vs bounded Y.Map ring-buffer.
  UX-scope dependent.
- E2 (Ch-H3): D1 actor tuple — add delegator? slot now for Q103
  agents-of-agents (deferred P2)?
- E3 (Ch-M5): "history" vs "journal" naming reopen (already adjudicated).
After /analyze of audit round-2 escalations + user decisions:

E1 REOPENED: D49 reverted to bounded Y.Map ring-buffer
- Y.Map('agent-effects') on each doc, keyed by sessionId:transactIdx
- Bounded at 50 entries total per doc (oldest-by-timestamp eviction)
- No CC1, no /api/activity-log REST endpoint, no server-side store
- Clients sync + subscribe via standard Y.Doc + Y.Map.observe
- ~10KB per doc bounded storage

Reason: journey re-read (P1-P5) showed no consumer needs retention
beyond hours. Historical attribution uses shadow/history git commit
bodies (D13), separate path. Server-side + CC1 + REST was
over-engineered for the actual scope.

FR-11 updated to match new D49 shape.
UM scope (FR-3) unchanged — agent-effects NOT in UM scope so undo
doesn't revert effect-diff log entries (undo-transacts create their
own entries; timeline renders write + undo narrative).

E2 KEEP DEFERRED (user decision): D1 stays two-slot. Q103 nested
agents remains P2. Additive schema evolution is safe either way.

E3 KEEP "history" (prior decision stands): challenger reopen
dismissed; alignment with /api/history + user mental model holds.

Spec state: 57 decisions + FR-20. All high/medium audit + challenger
findings closed. Ready for Task 11 (Verify and finalize).
H1: persistence.ts:405 → 388 across §1 + D31
H2: D27 keepalive close-handler moved from start.ts to boot.ts
M3: FR-18 parseWriterId + WRITER_ID_RE prerequisite called out
M4: FR-5 enumerates full 12-handler taxonomy per D42
M5: D42 line citations refreshed
M6: getSession citation 179-219 → 188-219
M7: TiptapEditor.tsx:236 → 257; test-harness.ts:538 → 635
M8: FR-20 added to §13 In scope roll-up
L9: Decision log gaps signposted in numbering notes
L10: D45 null projectDir path clarified

Audit artifact committed at specs/.../meta/audit-findings-2026-04-21.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rename all shadow-repo files/symbols/log-prefixes to history-* so
the naming reflects user-facing semantics (history repo backs
/api/history and the Timeline panel) and eliminates the accidental
'shadow' drift.

- packages/core/src/shadow-repo-layout.ts → history-repo-layout.ts
  ShadowContributor → HistoryContributor; package.json export updated
- packages/server/src/shadow-repo.ts → history-repo.ts
  ShadowHandle → HistoryHandle, initShadowRepo → initHistoryRepo,
  shadowGit → historyGit, [shadow] → [history] log prefixes
- packages/server/src/shadow-lock.ts → history-lock.ts
- packages/server/src/shadow-branch-gc.ts → history-branch-gc.ts
  gcShadowBranches → gcHistoryBranches
- All importers updated: standalone.ts, persistence.ts, api-extension.ts,
  server-observers.ts, server-observer-extension.ts, boot.ts,
  timeline-query.ts, hocuspocus-plugin.ts, shadow-log.ts, enrichment.ts
- HistorySource runtime values: 'shadow-repo' → 'history-repo',
  'shadow-repo-absent' → 'history-repo-absent'
- All 4 test files renamed; standalone.test.ts shadowHandle → historyHandle
- git ref namespace (refs/wip/*, refs/checkpoints/*) unchanged

Quality gate: bun run check — 14/14 tasks green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ityEntry → AgentFlashEntry

Disambiguates from D49's server-side activity log and D25's per-session
UndoManager scope. All 7 getMap('activity') call sites updated; type
renamed at the definition and all consumers.

- packages/core/src/types/awareness.ts: ActivityEntry → AgentFlashEntry
- packages/core/src/constants/activity.ts: import + cast updated
- packages/core/src/index.ts: re-export updated
- packages/server/src/api-extension.ts (3 sites): getMap renamed
- packages/app/src/editor/TiptapEditor.tsx: getMap renamed
- packages/app/src/editor/plugins/agent-flash-source.ts: getMap + comment
- packages/app/tests/integration/test-harness.ts: getMap renamed
- packages/app/src/editor/observers.test.ts: getMap renamed
- packages/app/src/server/agent-sim.ts: log comment updated

Quality gate: bun run check — 14/14 tasks green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
History repo always lives at <projectRoot>/.open-knowledge/history/
regardless of project git state. Legacy locations are migrated atomically
on first server start. .open-knowledge/ is added to .gitignore in all
modes (not just standalone).

- history-repo-layout.ts: resolveHistoryDir() returns a string (unified
  path), removes HistoryRepoMode / ResolvedHistoryDir bifurcation
- history-repo.ts: migrateHistoryRepo() checks .git/openknowledge/ and
  .openknowledge/ and renames to unified path; ensureGitignoreEntry()
  always writes .open-knowledge/ entry; initHistoryRepo() calls both
- history-repo-layout.test.ts: updated getHistoryRepoPath tests to
  reflect unified path; legacy locations explicitly do not satisfy query
- history-repo.test.ts: updated path assertions; added 2 migration tests
  for integrated-mode and standalone-mode legacy locations
- Migration log: [history-migration] relocated history from <old> to <new>

Quality gate: bun run check — 14/14 tasks green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Kind

Add shared Actor model types to core so downstream code imports
(principal, agent_session) tuple, Principal, SessionRecord shapes without
a server dep.

- packages/core/src/types/actor.ts: Actor, PrincipalId, SessionId
- packages/core/src/types/principal.ts: Principal (id, display_name,
  display_email, source, created_at)
- packages/core/src/types/awareness.ts: AgentFocusEntry.writeKind
  expanded to 'write' | 'edit' | 'undo' | 'rollback-apply' | null (D43)
- packages/core/src/index.ts: re-exports for Actor, PrincipalId,
  SessionId, Principal

Quality gate: bun run check — 14/14 tasks green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces a stable per-machine principal identity persisted under
.open-knowledge/principal.json, and a sanitization helper applied
at all identity input boundaries.

- packages/server/src/git-identity-sanitize.ts: strips <>, CR, LF,
  trims, slices to 128 chars; unit tests in .test.ts
- packages/server/src/principal.ts: loadPrincipal() reads/creates
  principal-<UUID> record; refreshes display fields from git config
  on each call, keeps id + created_at immutable; tests in .test.ts
- packages/server/src/api-extension.ts: extractAgentIdentity now
  calls sanitizeGitIdentity() for agentName and clientName instead
  of inline .replace(/[\r\n]/g,'')

Quality gate: bun run check — 14/14 tasks green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add SessionRecord type (dc, origin, agentId, docName)
- createSessionOrigin: deep-frozen PairedWriteOrigin per session (D2, D23)
- D30 in-flight promise dedup: concurrent getSession shares one DC
- All 4 agent write handlers use session.origin (D32 STOP rule)
- Remove stale AGENT_WRITE_ORIGIN import from api-extension.ts
- Update 3 test files (api-agent-frontmatter, api-agent-patch, on-agent-write)
  to use session.dc.document.* instead of dc.document.*
- FR-4 integration test updated to track session.origin via
  server.instance.sessionManager.getSession() instead of shared constant
- Add US-007 unit tests: concurrent dedup, deep-freeze TypeError, object identity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SessionRecord gains `um: Y.UndoManager` and `undoOrigin: LocalTransactionOrigin`.
UM tracks writes under session.origin across all three Y-types atomically;
captureTransaction excludes undoOrigin writes (V0-14 placeholder); ignoreRemoteMapChanges
prevents remote agent map updates from polluting the undo stack. All session close
paths (closeSession, closeAllForDoc, closeAllForAgent, closeAll) call um.destroy()
before dc.disconnect(). Unit tests updated with real Y.Doc mock; integration tests
cover S1/S2 independence, atomic multi-type undo, and post-destroy non-tracking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lers + meta-test

- Thread extractAgentIdentity through 15 mutating POST handlers in api-extension.ts:
  handleAgentWrite, handleAgentWriteMd, handleAgentPatch, handleAgentUndo,
  handleSaveVersion, handleRollback, handleCreatePage, handleRename,
  handleRenamePath, handleDeletePath, handleUploadImage, handleSyncTrigger,
  handleSyncSetEnabled, handleSyncAbortMerge, handleSyncResolveConflict
- Update MCP tools rename-document.ts + rollback-to-version.ts to accept identityRef
  and spread agentId/agentName/clientName/colorSeed into POST body
- Pass identityRef to registerRenameDocument + registerRollbackToVersion in index.ts
- Add attribution-sweep-coverage.test.ts meta-test: static analysis asserting all
  required POST handlers call extractAgentIdentity and no untracked handler can
  be added to the route registry without explicit classification (FR-5, D42)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Export resolveWriterFromOrigin() from persistence.ts: dispatches
  lastTransactionOrigin → WriterIdentity for all Hocuspocus origin shapes
  (local+session_id → agent-<id>, local+file-watcher → FILE_SYSTEM_WRITER,
  local+upstream-import → GIT_UPSTREAM_WRITER, local fallback →
  SERVICE_WRITER, connection+principalId → principal writer stub)
- onStoreDocument destructures lastTransactionOrigin and records contributor
  for non-service session-bearing origins (safety-net for writes that bypass
  api-extension.ts handlers)
- commitToWipRef uses contributor snapshot dispatch: first writer or
  SERVICE_WRITER fallback; replaces defaultWriter={id:'server',...} hardcode
- Add FILE_SYSTEM_WRITER, GIT_UPSTREAM_WRITER, SERVICE_WRITER constants to
  history-repo.ts (D34); export from packages/server/src/index.ts
- Rename UPSTREAM_WRITER → GIT_UPSTREAM_WRITER in commitUpstreamImport
- 11-test resolveWriterFromOrigin unit suite in persistence.test.ts covering
  all dispatch paths including null/undefined/non-object guard
- standalone.test.ts L2-flush test: check for any WIP ref (writer ID varies
  with module-level contributor state across concurrent tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Entry

- Add buildWipTree(shadow, contentRoot): stages content directory into a
  fresh index, returns tree SHA shared by all per-writer commits in the drain
- Add commitWipFromTree(shadow, writer, treeSha, message, branch): creates a
  commit from a pre-built tree SHA and advances the per-writer WIP ref; no
  staging, no tmpIndex per writer
- Export both from index.ts alongside existing commitWip
- Refactor commitToWipRef for per-writer fan-out (FR-7, US-014):
  - Non-empty snapshot: buildWipTree once → commitWipFromTree per writer
  - Per-writer failure: restoreContributorEntry(writerId, entry) + metric
  - All writers failed: bump consecutiveGitFailures + gitAutoSaveFailure
  - Empty snapshot: SERVICE_WRITER commitWip fallback (D32 unchanged)
- Export ContributorEntry type from contributor-tracker.ts; add
  restoreContributorEntry(agentId, entry) for per-writer attribution recovery
  on partial fan-out failure (D38)
- Add gitWriterCommitFailureCount to ReconciliationMetrics +
  incrementGitWriterCommitFailure() + resetMetrics() reset
- persistence-fan-out.test.ts: two-writer fan-out asserts both refs exist
  and share the same tree SHA; SERVICE_WRITER fallback asserts any WIP ref

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add formatReconcileSubject, formatRollbackSubject, formatParkSubject,
  formatRenameSubject, formatCheckpointSubject, formatImportSubject to
  history-repo-layout.ts (D53, FR-13); full test suite in layout.test.ts
- Add ok-actor: JSON body line to all history commits: commitUpstreamImport,
  safetyCheckpoint, parkBranch, saveVersion in history-repo.ts
- Add subjectOverride?: string to ContributorEntry; extend recordContributor
  signature; L2 drain in persistence.ts uses subjectOverride ?? formatWipSubject
- Thread rollback: and rename: subject overrides through api-extension.ts
  recordContributor calls for rollback and managed-rename handlers
- Fix timeline-query.ts classifyType to recognize import: prefix as 'upstream'
  (import: has been the actual prefix since the initial implementation)
- Tests: ok-actor round-trip for commitUpstreamImport, safetyCheckpoint,
  parkBranch, saveVersion; new safetyCheckpoint describe block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Rename ContributorEntry.agentId → writerId in contributor-tracker.ts
  to accept any writer taxonomy value (agent-<uuid>, file-system, etc.)
- external-change.ts: record FILE_SYSTEM_WRITER contributor with
  reconcile: subject after each file-watcher transact (D41)
- persistence.ts: when onStoreDocument finds markdown === reconciledBase
  but contributors are pending, still call scheduleGitCommit() so the
  L2 drain fires at graceful shutdown (skipStoreHooks path, FR-6)
- persistence-fan-out.test.ts: two new US-016 integration tests —
  file-system ref exists with reconcile: subject, and concurrent
  agent + file-watcher commits share the same tree SHA (FR-7)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e writer

PARK_SNAPSHOT_ORIGIN defined in standalone.ts (paired:true, D39);
park loop wrapped in doc.transact atomically; setBatchInProgress before loop;
park uses SERVICE_WRITER.id (openknowledge-service, D58).
Fixed lint: import ordering + line-length in standalone.ts and history-repo.test.ts.
…ream (D35, NFR-6)

sweepLegacyHistoryRefs() enumerates refs/wip/*/*, deletes only refs
whose parseWriterId().classification === 'unknown' AND match the
known-legacy allowlist (server, human-*, upstream). New taxonomy refs
(agent-*, principal-*, file-system, git-upstream, openknowledge-service)
are preserved. Called from initHistoryRepo; idempotent.
historyMigrationLegacyRefsDeleted metric added to metrics.ts.
3 unit tests: mixed-ref sweep, idempotence, fresh-repo no-op.
SESSION_WRITER_TTL_MS=30d constant; GcResult.deletedStaleSessionRefs counter;
per-writer TTL loop in gcHistoryBranches that GC's stale agent-*/principal-*
refs on active project branches while preserving classified writers and fresh
session refs. Fixed early-return control flow so TTL runs even when no orphaned
branches exist. Tests: stale-session deletion + fresh-session preservation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…thor

swapContributors() drains attribution atomically at save-version time;
agent-*/principal-* contributors become Co-Authored-By trailers in the
parent-git commit. Author/Committer identity: body principal > git config
> openknowledge fallback. formatCheckpointSubject() wraps subject line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… D33)

Add git-repo availability check via revparse(--git-dir) before acquiring the
parent-git lock. Non-git dir: response 200 with checkpointRef, versionTag
omitted, [save-version] parent-git unavailable: warn emitted. Git dir: full
flow with Co-Authored-By trailers and ok/vN tag. History checkpoint via
commit-tree plumbing always runs regardless. 3 integration tests: non-git,
git, and state-transition (non-git → git init → fresh tag).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
miles-kt-inkeep and others added 2 commits April 22, 2026 12:20
- activity-log: add EFFECT_CAPTURE_ORIGIN (precedent #1) and unobserve on doc destroy to prevent observer leak
- activity-log.test: replace unreachable metric assertion with real error-path trigger; add doc-destroy observer cleanup test
- persistence: null-check principal display_name/display_email before returning writer identity
- agent-sessions: document why Y.Map('agent-flash') is tracked by the UndoManager
- shadow-repo: sweep orphaned index-wip-fanout-* temp files in initShadowRepo (pre-lock, safe)
- agent-undo.test: replace literal origin shape assertion with real sessionManager-driven inspection

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MCP subprocesses were opening the keepalive WS without a connectionId query
param, so the server-side close handler in boot.ts silently early-returned and
never invoked closeAllForAgent. Ghost agents persisted in awareness (visible in
presence bar) until 30-min idle-shutdown or server restart.

Fix: pass connectionId: \`agent-\${connectionId}\` to startKeepalive — the
prefixed form matches the session-key format used by extractAgentIdentity and
closeAllForAgent, matching the session-cleanup.test.ts contract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions github-actions Bot deleted a comment from inkeep-internal-ci Bot Apr 22, 2026
Copy link
Copy Markdown

@inkeep-internal-ci inkeep-internal-ci Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(5) Total Issues | Risk: Medium

🟠⚠️ Major (2) 🟠⚠️

Inline Comments:

  • 🟠 Major: agent-sessions.ts:468 closeAllForAgent does not cancel in-flight pendingSessions
  • 🟠 Major: api-extension.ts:1944 extractAgentIdentity result discarded — undo operations unattributed

🟡 Minor (3) 🟡

Inline Comments:

  • 🟡 Minor: api-extension.ts:1991 No indication when undo stack was empty
  • 🟡 Minor: agent-sessions.ts:196 scope='session' branch lacks test coverage
  • 🟡 Minor: boot.ts:293 TOCTOU race between grace timer check and closeAllForAgent

💭 Consider (0) 💭

None.

🧹 While You're Here (0) 🧹

None.

🕐 Pending Recommendations (8)

Prior review findings that remain applicable:


🚫 REQUEST CHANGES

Summary: This is a well-architected identity and attribution foundation with solid design decisions (per-session frozen origins, XmlFragment-authoritative undo, structured ok-actor: body). The two blocking issues are:

  1. Session cleanup gap: closeAllForAgent only iterates this.sessions, not this.pendingSessions. A slow openDirectConnection during the 30s keepalive grace window can race against cleanup, leaving dangling sessions.

  2. Attribution gap in undo handler: handleAgentUndo calls extractAgentIdentity but discards the result without calling recordContributor, so undo operations won't be properly attributed in the shadow repo.

Both are straightforward fixes. The Minor items (undo no-op feedback, scope='session' test coverage, grace timer race) are lower priority but worth addressing.


Discarded (3)
Location Issue Reason Discarded
api-extension.ts:1088 Self-asserted agent identity enables impersonation Local-only server (loopback CORS), no auth model in scope — risk accepted for this iteration
agent-sessions.ts:325 Sessions Map unbounded memory Low risk: sessions are cleaned up on keepalive close; would need sustained malicious reconnects to exploit
contributor-tracker.ts:54 pendingContributors memory leak L2 drain fires on 2s debounce; would need complete persistence failure to accumulate
Reviewers (6)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-standards 7 0 0 0 2 0 5
pr-review-security-iam 4 0 0 0 0 0 4
pr-review-tests 6 0 0 0 1 0 5
pr-review-sre 6 0 0 0 1 0 5
pr-review-errors 5 0 0 0 1 0 4
pr-review-consistency 0 0 0 0 0 0 0
Total 28 0 0 0 5 0 23

Note: High discard rate reflects that many findings duplicated prior review comments or were assessed as low-risk for this local-only iteration.

Comment thread packages/server/src/agent-sessions.ts Outdated
async closeAllForAgent(agentId: string): Promise<void> {
const suffix = `\0${agentId}`;
for (const [key, dc] of this.sessions) {
for (const [key, session] of this.sessions) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR: Concurrent Map modification in closeAllForAgent

Issue: The method iterates over this.sessions with a for...of loop while calling await session.dc.disconnect() and this.sessions.delete(key) inside the loop. If disconnect() triggers any callback that causes another session operation, it could modify the sessions Map during iteration.

Why: While ES6 Maps tolerate deletion of the current key during iteration, other session operations triggered by the async disconnect could add or remove different keys, causing unpredictable iteration behavior.

Fix: Collect matching keys first, then iterate:

Suggested change
for (const [key, session] of this.sessions) {
async closeAllForAgent(agentId: string): Promise<void> {
const suffix = `\0${agentId}`;
const keysToClose = [...this.sessions.keys()].filter(k => k.endsWith(suffix));
for (const key of keysToClose) {
const session = this.sessions.get(key);
if (!session) continue;

Refs:

@@ -262,11 +487,12 @@ export class AgentSessionManager {
/** Close all sessions for a given document (all agents). */
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR: Same concurrent modification issue in closeAllForDoc

Issue: This method also iterates directly over this.sessions while performing async operations and deleting entries.

Why: Same race condition as closeAllForAgent - cascading cleanup or event handlers could modify the sessions Map during iteration.

Fix: Apply the same pattern:

Suggested change
/** Close all sessions for a given document (all agents). */
async closeAllForDoc(docName: string): Promise<void> {
const prefix = `${docName}\0`;
const keysToClose = [...this.sessions.keys()].filter(k => k.startsWith(prefix));
for (const key of keysToClose) {
const session = this.sessions.get(key);
if (!session) continue;

Refs:

Comment thread packages/server/src/boot.ts Outdated
clearInterval(pingTimer);
if (!connectionId) return;
// D28: start grace timer.
const timer = setTimeout(async () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: Grace timer callback captures potentially reassignable variable

Issue: The setTimeout callback captures connectionId which could theoretically be reassigned between timer set and fire (unlikely but defensive coding should avoid).

Why: Using a const binding ensures the captured value cannot change, making the code more predictable and easier to reason about.

Fix: Capture in a const before setting the timer:

if (!connectionId) return;
const capturedId = connectionId; // const binding cannot be reassigned
const timer = setTimeout(async () => {
  keepaliveGraceTimers.delete(capturedId);
  // ... use capturedId instead of connectionId
}, KEEPALIVE_GRACE_MS);

Refs:

message: string,
branch = 'main',
): Promise<string> {
const ref = `refs/wip/${branch}/${writer.id}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: Ref path construction does not validate writer.id format

Issue: The ref path is constructed as refs/wip/${branch}/${writer.id}. If a malformed writerId containing path separators (e.g., ../main/attacker) were to flow through, it could target arbitrary refs.

Why: While current code paths produce safe IDs, this is a defense-in-depth gap. The parseWriterId function validates IDs on read but not on write.

Fix: Add validation that writer.id matches the WRITER_ID_RE pattern:

import { WRITER_ID_RE } from '@inkeep/open-knowledge-core';

// Before constructing ref path:
if (!WRITER_ID_RE.test(writer.id)) {
  throw new Error(`Invalid writer.id format: ${writer.id}`);
}

Refs:

email: `${principalId}@openknowledge.local`,
};
}
return SERVICE_WRITER;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: Silent fallback to SERVICE_WRITER may obscure attribution bugs

Issue: resolveWriterFromOrigin returns SERVICE_WRITER as a catch-all for unrecognized origins. Any new origin type added without updating this dispatch table will have its writes silently attributed to 'openknowledge-service'.

Why: This makes it easy to miss attribution bugs during development when adding new origin types.

Fix: Log a warning when falling back to SERVICE_WRITER for unexpected origin shapes:

// Before the final return SERVICE_WRITER:
console.warn(JSON.stringify({
  event: 'unknown-origin-fallback',
  origin: origin?.context?.origin ?? 'undefined',
  source: (origin as any)?.source ?? 'undefined'
}));
return SERVICE_WRITER;

Refs:

@@ -243,11 +467,12 @@ export class AgentSessionManager {
/** Close all sessions for a given agent (across all docs). */
async closeAllForAgent(agentId: string): Promise<void> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR: closeAllForAgent does not cancel in-flight pendingSessions

Issue: This method iterates this.sessions but not this.pendingSessions (line 327). If the keepalive-grace timer fires while a getSession() call is in-flight (awaiting openDirectConnection), the pending session will complete and register itself after cleanup, creating a dangling session.

Why: The 30s keepalive grace window provides ample time for a slow openDirectConnection (network hiccup, server load) to race against cleanup. A dangling session leaks memory and may cause closeAllForAgent to appear successful while the session remains active, violating the MCP subprocess exit cleanup contract.

Fix: Also iterate and await/cancel pendingSessions entries for the agent:

// Cancel any pending sessions for this agent
for (const [key, pending] of this.pendingSessions) {
  if (key.endsWith(suffix)) {
    this.pendingSessions.delete(key);
    // Optionally await + close the session if it completes
  }
}

Refs:

Comment thread packages/server/src/api-extension.ts Outdated
return;
}

extractAgentIdentity(body); // attribution threading (FR-5, D42)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR: extractAgentIdentity result discarded — undo operations unattributed

Issue: This line calls extractAgentIdentity(body) but discards the returned AgentIdentity. Unlike other handlers (e.g., handleAgentWrite at line 1088), there is no recordContributor() call to attribute the undo operation to the agent.

Why: Undo operations mutate Y.Doc state and flow through to L2 drain commits. Without recordContributor(), the undo will be attributed to the wrong writer (likely openknowledge-service fallback), breaking per-agent attribution in the shadow repo and causing the Timeline UI to show incorrect authorship for undo operations.

Fix: Thread identity through to recordContributor:

const identity = extractAgentIdentity(body);
// ... after successful applyAgentUndo ...
recordContributor(
  docName,
  `agent-${identity.connectionId}`,
  identity.displayName,
  identity.colorSeed,
  `undo: ${docName}`,
  { agentType: identity.agentType, clientName: identity.clientName, ... }
);

Refs:

Comment thread packages/server/src/api-extension.ts Outdated
ts: Date.now(),
});

json(res, 200, { ok: true, docName, scope });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: No indication when undo stack was empty

Issue: handleAgentUndo returns { ok: true } regardless of whether any undo operation actually occurred. If the undo stack was empty, the caller has no way to know their request was a no-op.

Why: MCP clients need feedback to decide whether to retry or report "nothing to undo" to the user. Silent success on no-op creates a confusing UX where the agent believes it undid something when nothing changed.

Fix: Return a discriminated response:

const result = await applyAgentUndo(session, scope);
return json({ 
  ok: true, 
  undone: result.undone, // boolean or count
  message: result.undone ? 'Undo applied' : 'Nothing to undo'
});

Refs:

Comment thread packages/server/src/agent-sessions.ts Outdated
*
* @see PRECEDENTS.md precedent #10 (XmlFragment-authoritative writes)
*/
export function applyAgentUndo(session: SessionRecord, scope: 'last' | 'session'): void {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: scope='session' branch lacks test coverage

Issue: The applyAgentUndo function handles both scope='last' (undo one transaction) and scope='session' (clear entire session stack), but the test suite only exercises scope='last'. The while loop for scope='session' is untested.

Why: If the scope='session' loop has a bug (e.g., infinite loop if um.canUndo() returns stale state, or partial undo on error), it won't be caught until production. Given that scope='session' wipes all agent edits, bugs here could cause data loss.

Fix: Add test coverage for scope='session':

test('applyAgentUndo scope=session undoes entire stack', async () => {
  // Make 3 edits
  await agentWrite(port, docName, 'first');
  await agentWrite(port, docName, 'second');
  await agentWrite(port, docName, 'third');
  // Undo all
  await fetch(`/api/agent-undo`, {
    body: JSON.stringify({ docName, connectionId, scope: 'session' })
  });
  // Assert content is back to initial state
});

Refs:

} catch (err) {
log.error({ err, connectionId }, '[keepalive] clearFocus failed');
}
}, KEEPALIVE_GRACE_MS);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: TOCTOU race between grace timer check and closeAllForAgent

Issue: If destroy() is called while a grace timer's callback is executing (between keepaliveGraceTimers.delete() at line 281 and closeAllForAgent() at line 284), the destroy() loop may miss the timer that already deleted itself from the Map, allowing closeAllForAgent to run against a partially-disposed sessionManager.

Why: While unlikely in practice (30s grace >> typical destroy timing), the race can cause intermittent errors during shutdown that are difficult to diagnose. The timer callback's async operations (closeAllForAgent, clearFocus) may throw or hang if the underlying resources are being torn down.

Fix: Add a "shutting down" guard:

let shuttingDown = false;
// In timer callback:
if (shuttingDown) return;
// In destroy():
shuttingDown = true;

Or use a mutex/AbortController pattern for coordinated shutdown.

Refs:

@github-actions github-actions Bot deleted a comment from inkeep-internal-ci Bot Apr 22, 2026
miles-kt-inkeep and others added 2 commits April 22, 2026 13:14
GET /api/document previously called sessionManager.getSession(docName)
with no identity. That created a cached session with undefined
clientName, which made iconFromClientName() return 'bot' and surfaced
a generic "Agent" avatar in the presence bar that persisted until the
30-minute idle-shutdown.

Swap to a transient hocuspocus.openDirectConnection(docName): reads the
Y.Text('source') without touching awareness, then disconnects. No
session cache, no ghost presence entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions github-actions Bot deleted a comment from inkeep-internal-ci Bot Apr 22, 2026
Copy link
Copy Markdown

@inkeep-internal-ci inkeep-internal-ci Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(5) Total Issues | Risk: Medium

🟠⚠️ Major (2) 🟠⚠️

No new Major findings in this review cycle. See Pending Recommendations below for unresolved prior findings.

🟡 Minor (3) 🟡

No new Minor findings in this review cycle. See Pending Recommendations below for unresolved prior findings.

💭 Consider (0) 💭

None.

🧹 While You're Here (0) 🧹

None.

✅ Addressed Since Last Review

🟠 Concurrent Map modification in closeAllForAgent and closeAllForDoc — Both methods now use the snapshot pattern (const keys = [...this.sessions.keys()].filter(...)) before iterating, which prevents concurrent modification issues. This addresses the prior finding at agent-sessions.ts:485.

🕐 Pending Recommendations (5)

Prior review findings that remain applicable and unresolved:

  • 🟠 agent-sessions.ts:468 closeAllForAgent does not cancel in-flight pendingSessions — if keepalive-grace timer fires while a getSession() call is in-flight, the pending session will complete and register itself after cleanup
  • 🟠 api-extension.ts:1956 extractAgentIdentity result discarded in handleAgentUndo — no recordContributor() call means undo operations won't be attributed to the correct agent in shadow repo
  • 🟡 agent-sessions.ts:196 scope='session' branch lacks test coverage — the while loop for clearing entire session stack is untested
  • 🟡 api-extension.ts:2003 No indication when undo stack was empty — handleAgentUndo returns { ok: true } regardless of whether any undo occurred
  • 🟡 boot.ts:293 TOCTOU race between grace timer check and closeAllForAgent — if destroy() is called while a grace timer callback is executing, the callback may run against partially-disposed resources

🚫 REQUEST CHANGES

Summary: Two Major issues from the prior review remain unaddressed: (1) closeAllForAgent does not handle in-flight pendingSessions, creating a race condition that can leak sessions; (2) handleAgentUndo discards the agent identity without recording the contributor, breaking attribution for undo operations. The concurrent Map modification issue was successfully fixed with the snapshot pattern. Please address the two Major findings before merge.

Discarded (1)
Location Issue Reason Discarded
agent-sessions.ts:485 Concurrent Map modification in closeAllForDoc RESOLVED — Code now uses snapshot pattern at line 488: const keys = [...this.sessions.keys()].filter((k) => k.startsWith(prefix))
Reviewers (1)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
manual-review 6 0 0 0 0 5 1
Total 6 0 0 0 0 5 1

Note: Task subagents for domain-specific reviewers failed to spawn; findings are from manual verification of prior review issues.

@github-actions github-actions Bot deleted a comment from inkeep-internal-ci Bot Apr 22, 2026
- thread recordContributor + flushDocToGit through handleAgentUndo so
  per-session undo writes keep shadow-repo attribution (FR-5/D42). No-op
  undo (empty UM stack) is skipped — nothing to attribute.
- close a TOCTOU race in bootServer between keepalive-grace timer
  callbacks and destroy(): timers short-circuit on a shuttingDown flag,
  and destroy() awaits any inflight cleanup work via Promise.allSettled.
- drain pendingSessions for the agent inside closeAllForAgent so an
  in-flight getSession() can't register AFTER the key-scan pass.
- applyAgentUndo now returns a boolean so the HTTP response and
  attribution gate can distinguish a real undo from an empty-stack no-op.
- add scope='session' integration test — drains multi-frame UM stack,
  asserts undone:true, verifies empty-stack second call returns
  undone:false, and checks the bridge invariant post-drain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown

@inkeep-internal-ci inkeep-internal-ci Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(0) Total Issues | Risk: Low

✅ Addressed Since Last Review

All Major and Minor issues from prior review cycles have been resolved:

🟠 closeAllForAgent does not cancel in-flight pendingSessions — RESOLVED. The method now awaits pending sessions at lines 484-493:

const pendingKeys = [...this.pendingSessions.keys()].filter((k) => k.endsWith(suffix));
if (pendingKeys.length > 0) {
  await Promise.allSettled(pendingKeys.map((k) => this.pendingSessions.get(k)));
}

🟠 extractAgentIdentity result discarded — undo operations unattributed — RESOLVED. handleAgentUndo now calls recordContributor when result.undone is true (lines 1996-2008).

🟠 Concurrent Map modification in closeAllForAgent and closeAllForDoc — RESOLVED. Both methods now use the snapshot pattern (const keys = [...this.sessions.keys()].filter(...)) before iterating.

🟡 scope='session' branch lacks test coverage — RESOLVED. New test at agent-undo.test.ts:113 exercises the while loop with 3 writes, full drain, and empty-stack edge case.

🟡 No indication when undo stack was empty — RESOLVED. applyAgentUndo now returns { undone: boolean } and the handler response includes this field.

🟡 TOCTOU race between grace timer check and closeAllForAgent — RESOLVED. Added shuttingDown guard at lines 286-292 with check in timer callback.

🟡 Missing explicit origin on doc.transact() — RESOLVED. EFFECT_CAPTURE_ORIGIN is now properly defined (lines 28-32) with paired: false and used in the transact call (line 85).

🕐 Pending Recommendations (6)

Prior review findings that remain applicable as minor/consider improvements:


💡 APPROVE WITH SUGGESTIONS

Summary: This is a well-executed implementation of the agent identity & attribution foundation. All 7 Major/Minor issues from the 3 prior review cycles have been addressed — the scope='session' test coverage was the last outstanding item and is now complete. The remaining 6 Pending Recommendations are minor defense-in-depth improvements and documentation suggestions that don't block merge. The code follows established precedents (#1, #10, #14, #24, #25) and the test coverage is comprehensive.


Reviewers (1)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review (orchestrator) 20 0 0 0 0 6 14
Total 20 0 0 0 0 6 14

Note: Manual review due to subagent dispatch failures. Verified all prior findings against current HEAD.

@github-actions github-actions Bot deleted a comment from inkeep-internal-ci Bot Apr 22, 2026
Copy link
Copy Markdown

@inkeep-internal-ci inkeep-internal-ci Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(0) Total Issues | Risk: Low

✅ All Prior Issues Resolved

All Major and Minor issues from prior review cycles have been verified as resolved:

Prior Finding Status Evidence
🟠 closeAllForAgent does not cancel in-flight pendingSessions ✅ RESOLVED Lines 484-493 now await pending sessions before draining
🟠 extractAgentIdentity result discarded in handleAgentUndo ✅ RESOLVED Lines 1999-2008 now call recordContributor when undone=true
🟠 Concurrent Map modification in closeAllForAgent/closeAllForDoc ✅ RESOLVED Both methods use snapshot pattern [...this.sessions.keys()].filter()
🟡 scope='session' branch lacks test coverage ✅ RESOLVED New test at lines 113-158 exercises full drain
🟡 No indication when undo stack was empty ✅ RESOLVED applyAgentUndo returns { undone: boolean }
🟡 TOCTOU race in grace timer ✅ RESOLVED shuttingDown guard at line 292
🟡 Missing explicit origin on doc.transact() ✅ RESOLVED EFFECT_CAPTURE_ORIGIN defined with paired: false (lines 28-32)

💭 Consider (2) 💭

💭 1) agent-undo.test.ts:86 Multi-client undo test doesn't assert undone field

Issue: The multi-client test asserts undoRes.ok but doesn't verify the undone field in the response body.
Why: The scope='session' test properly asserts body.undone === true (line 138), so this is only a minor coverage gap for the multi-client scenario.
Fix: Add const body = await undoRes.json(); expect(body.undone).toBe(true); after line 86.
Refs: agent-undo.test.ts:86

💭 2) agent-undo.test.ts:75,89 Fixed wait() calls instead of event-driven sync

Issue: Tests use hardcoded wait(600) delays for CRDT propagation instead of event-driven synchronization.
Why: While functional, this pattern can cause flakiness under load or slow CI runners. The awaitDocQuiescence primitive exists in the test harness for this purpose.
Fix: Consider replacing wait(600) with assertAllConverged or awaitDocQuiescence for more deterministic sync.
Refs: test-harness.ts:awaitDocQuiescence

🕐 Pending Recommendations (6)

Prior review findings that remain applicable as minor/consider improvements:


✅ APPROVE

Summary: This PR establishes a coherent agent identity and attribution foundation with per-session origins, per-session UndoManager, classified writer IDs, and structured ok-actor: commit bodies. All Major and Minor issues from the 4 prior review cycles have been verified as resolved. The remaining Consider items are minor test coverage improvements that don't block merge. The architecture is sound, the test coverage is comprehensive (27 user stories, 15 POST handlers threaded, 3 new integration test files), and the documentation is thorough.

Discarded (8)
Location Issue Reason Discarded
api-extension.ts Session ownership validation gap Explicitly deferred per SPEC §15 "Identity attestation" — accepted scope
standalone.ts Principal token mismatch during startup Intentional design — fallback to SERVICE_WRITER is documented behavior (line 968-971)
api-extension.ts Agent identity impersonation risk Same as above — cryptographic verification deferred to future milestone
api-extension.ts:595-596 IPv6 loopback check redundancy Both ::1 and [::1] forms are needed — browsers send either depending on URL construction
session-cleanup.test.ts NFR-5 soak bypasses captureEffect path NFR-5 tests session lifecycle, not activity log — different concern
agent-sessions.test.ts closeSession UM destroy test weak Single-session test is sufficient for UM destroy verification
Various Architecture findings pr-review-architecture returned no issues — noted architectural strengths
Various Standards findings pr-review-standards returned no issues
Reviewers (4)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-standards 0 0 0 0 0 0 0
pr-review-security-iam 4 0 0 0 0 0 4
pr-review-tests 6 0 2 0 0 0 4
pr-review-architecture 0 0 0 0 0 0 0
Total 10 0 2 0 0 0 8

@github-actions github-actions Bot deleted a comment from inkeep-internal-ci Bot Apr 22, 2026
@miles-kt-inkeep miles-kt-inkeep merged commit d60c3e9 into main Apr 22, 2026
9 checks passed
@miles-kt-inkeep miles-kt-inkeep deleted the worktree-agent-identity-worldmodel branch April 22, 2026 18:48
Copy link
Copy Markdown

@inkeep-internal-ci inkeep-internal-ci Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(0) Total Issues | Risk: Low

✅ All Prior Issues Resolved

All Major and Minor issues from prior review cycles have been verified as resolved:

Prior Finding Status Evidence
🟠 closeAllForAgent does not cancel in-flight pendingSessions ✅ RESOLVED Lines 499-508 now await pending sessions before draining
🟠 extractAgentIdentity result discarded in handleAgentUndo ✅ RESOLVED Lines 2003-2052 now call recordContributor when undone=true
🟠 Concurrent Map modification in closeAllForAgent/closeAllForDoc ✅ RESOLVED Both methods use snapshot pattern [...this.sessions.keys()].filter()
🟡 scope='session' branch lacks test coverage ✅ RESOLVED Test at lines 113-158 exercises full drain
🟡 No indication when undo stack was empty ✅ RESOLVED applyAgentUndo returns { undone: boolean }
🟡 TOCTOU race in grace timer ✅ RESOLVED shuttingDown guard at line 245, checked at 292, set at 405

✅ Merge Changes Verified

The recent merge from origin/main introduced well-implemented fixes:

  1. Double-prefix fix (agent-sessions.ts:393-409): Strips agent- prefix from agentId before creating session origins to avoid agent-agent-<id> phantom writers. Comprehensive comments explain the fix. Test coverage at agent-sessions.test.ts:238-250 verifies both prefixed and unprefixed cases.

  2. git mv rename support (api-extension.ts:439-478): New renameTrackedPathInGit function preserves git history for tracked files with proper error handling and fallback to filesystem rename. Test coverage at api-file-ops.test.ts:463-522.

🕐 Pending Recommendations (6)

Prior review findings that remain applicable as minor/consider improvements:


✅ APPROVE

Summary: This PR establishes a solid foundation for agent identity and attribution across the CRDT layer. All Major and Minor issues from prior reviews have been addressed. The recent merge from origin/main introduced well-tested fixes for the double-prefix bug and git history preservation. The 6 pending recommendations are all minor/consider-level improvements that don't block merge. Ship it! 🚀

Reviewers (2)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-standards 0 0 0 0 0 0 0
pr-review-tests 0 0 0 0 0 0 0
Total 0 0 0 0 0 0 0

Note: Focused re-review after prior APPROVED cycle. Dispatched targeted reviewers to verify merge changes only.

@github-actions github-actions Bot deleted a comment from inkeep-internal-ci Bot Apr 22, 2026
miles-kt-inkeep added a commit that referenced this pull request Apr 22, 2026
… merge

During the rebase onto origin/main (post-#222 foundation), the merge
resolution in three api-extension.ts call sites incorrectly destructured a
non-existent top-level `hint` field from `summaryResponseFields(normalized)`.
The function returns `{response?: SummaryResponse; stored}` with `hint`
nested inside `response`. Since neither of the three response bodies
actually emits a top-level `hint` key anymore (the nested `summaryResponse.hint`
is the single source of truth that ships to the caller), the `summaryHint`
variable was dead in all three sites. Drop the declarations.

Sites:
- handleAgentWriteMd — write path JSON response
- handleRollback — D22-guarded response
- handleRename — D22-guarded response

Typecheck + lint green across core + server + cli.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nick-inkeep added a commit that referenced this pull request Apr 22, 2026
Proper merge commit with origin/main's cd4187c as second parent.
Resolves conflicts across 7 files (AGENTS.md, packages/app/package.json,
packages/app/src/server/hocuspocus-plugin.ts, packages/server/src/{api-extension,boot,persistence,standalone}.ts)
by unioning both sides — this branch's streaming-upload + asset-embed
surface (US-013, FR-3b resolveEmbed, upload.maxBytes removal) and main's
agent presence / principal identity / keepalive grace timers / ensureProjectGit
wiring (PRs #222, #246, #244, #267). New E2E tests added: handoff,
multi-agent-presence. Added handleUploadConfigGet to the
attribution-sweep exempt list (GET-only, no writes).

Quality gate: bun run check 15/15 tasks green
(938 expect() calls across 211 integration tests; 0 fail).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
miles-kt-inkeep added a commit that referenced this pull request Apr 23, 2026
…utors emit

Completes the foundation team's deferred read-path migration (SPEC
2026-04-18, FR-8 / D13). Before this change the shadow-repo commit body
carried BOTH `ok-contributors:` (from attribution PR #134, 2026-04-15)
AND `ok-actor:` (from foundation PR #222, 2026-04-21). The foundation
landed `ok-actor:` + formatOkActor + parseOkActor as the canonical
structured-tuple body line, but left `ok-contributors:` in the write
path because both live readers (timeline-query.ts, shadow-log.ts) still
used parseContributors. Result: parseOkActor had zero production
callers on main until this commit — it was write-only.

This commit retires `ok-contributors:` from the write path in one atomic
schema migration and wires the two live readers through a new dispatcher
that prefers ok-actor and falls back to ok-contributors for legacy
on-disk commits (greenfield directive waives on-disk back-compat, but
keeping the dispatcher means git-log on pre-migration commits stays
legible for $0 extra maintenance cost).

core/shadow-repo-layout.ts
  - `OkActorEntry` gains `writer_id: string` (the ref-name — the thing
    ok-contributors.id carried pre-consolidation). Makes commit bodies
    self-describing: `git show -s <sha>` → full attribution without a
    join against `git for-each-ref`. Also disambiguates classified
    writers, which otherwise share `{principal: null, agent_session: null}`.
  - `OkActorEntry.summaries?: string[]` — moves from ok-contributors to
    ok-actor as part of the migration. Emitted only when non-empty
    (legacy byte-identity for summary-less writes). Malformed parse drops
    just the field (D27 divergence).
  - `parseOkActors(body): OkActorEntry[]` — plural. FR-7 per-writer
    fan-out emits one ok-actor per writer in multi-contributor drains.
  - `parseOkActor` gains back-compat derivation for `writer_id` when
    reading pre-consolidation ok-actor commits: agent_session →
    `agent-<session>`, principal → `<principal>`, classified writers
    disambiguated by display_name with an `openknowledge-service`
    fallback. Explicit writer_id in stored JSON wins.
  - `okActorToShadowContributor(a)` — thin adapter projecting onto the
    legacy DTO the UI + CLI consume. Keeps those rendering surfaces
    unchanged; rich actor data (agent_type, client_name, …) stays
    available for future adoption.
  - `readContributors(body)` — single dispatcher: prefers ok-actor,
    falls back to parseContributors. Transitional commits with BOTH
    lines prefer ok-actor (no double-counting).
  - `parseContributors` (legacy reader) preserved for the fallback path
    + for tests that synthesize legacy bodies. `formatContributors*` in
    contributor-tracker.ts is now production-dead but kept as a test
    utility for legacy-body generation.

server/persistence.ts
  - L2 drain per-writer loop: drop `ok-contributors:` inline emission,
    keep only `formatOkActor(actorEntry)` with writer_id + summaries
    populated.
  - Service-writer fallback path (zero-contributor drain): populate
    writer_id.

server/shadow-repo.ts
  - Populate writer_id on all four direct-OkActorEntry emit sites:
    commitUpstreamImport, safetyCheckpoint, parkBranch,
    saveInMemoryCheckpoint.

server/timeline-query.ts + cli/content/shadow-log.ts
  - Switch `parseContributors(rawBody)` → `readContributors(rawBody)`.
    One line each. Consumer DTO (`ShadowContributor`) unchanged; the
    UI + CLI render paths see identical data regardless of which body
    line format lives on disk.

core/shadow-repo-layout.test.ts
  - Update `baseEntry` / `sparse` fixtures with writer_id.
  - 22 new tests covering writer_id (explicit + 5 derivation paths),
    summaries round-trip on ok-actor (including malformed field-drop),
    `parseOkActors` (plural — valid, skip-malformed, empty), and
    `readContributors` dispatcher (modern-only, legacy-only, both-prefer-
    ok-actor, multi-writer).

`bun run check` green: 15/15 turbo tasks. All 58 summary-related tests
pass; shadow-repo + timeline-query + branch-gc tests pass in isolation
(flakes under full-suite concurrent bun invocation are pre-existing
timing sensitivity in the git-op-heavy tests, unrelated to this change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
miles-kt-inkeep added a commit that referenced this pull request Apr 23, 2026
… merge

During the rebase onto origin/main (post-#222 foundation), the merge
resolution in three api-extension.ts call sites incorrectly destructured a
non-existent top-level `hint` field from `summaryResponseFields(normalized)`.
The function returns `{response?: SummaryResponse; stored}` with `hint`
nested inside `response`. Since neither of the three response bodies
actually emits a top-level `hint` key anymore (the nested `summaryResponse.hint`
is the single source of truth that ships to the caller), the `summaryHint`
variable was dead in all three sites. Drop the declarations.

Sites:
- handleAgentWriteMd — write path JSON response
- handleRollback — D22-guarded response
- handleRename — D22-guarded response

Typecheck + lint green across core + server + cli.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
miles-kt-inkeep added a commit that referenced this pull request Apr 23, 2026
…utors emit

Completes the foundation team's deferred read-path migration (SPEC
2026-04-18, FR-8 / D13). Before this change the shadow-repo commit body
carried BOTH `ok-contributors:` (from attribution PR #134, 2026-04-15)
AND `ok-actor:` (from foundation PR #222, 2026-04-21). The foundation
landed `ok-actor:` + formatOkActor + parseOkActor as the canonical
structured-tuple body line, but left `ok-contributors:` in the write
path because both live readers (timeline-query.ts, shadow-log.ts) still
used parseContributors. Result: parseOkActor had zero production
callers on main until this commit — it was write-only.

This commit retires `ok-contributors:` from the write path in one atomic
schema migration and wires the two live readers through a new dispatcher
that prefers ok-actor and falls back to ok-contributors for legacy
on-disk commits (greenfield directive waives on-disk back-compat, but
keeping the dispatcher means git-log on pre-migration commits stays
legible for $0 extra maintenance cost).

core/shadow-repo-layout.ts
  - `OkActorEntry` gains `writer_id: string` (the ref-name — the thing
    ok-contributors.id carried pre-consolidation). Makes commit bodies
    self-describing: `git show -s <sha>` → full attribution without a
    join against `git for-each-ref`. Also disambiguates classified
    writers, which otherwise share `{principal: null, agent_session: null}`.
  - `OkActorEntry.summaries?: string[]` — moves from ok-contributors to
    ok-actor as part of the migration. Emitted only when non-empty
    (legacy byte-identity for summary-less writes). Malformed parse drops
    just the field (D27 divergence).
  - `parseOkActors(body): OkActorEntry[]` — plural. FR-7 per-writer
    fan-out emits one ok-actor per writer in multi-contributor drains.
  - `parseOkActor` gains back-compat derivation for `writer_id` when
    reading pre-consolidation ok-actor commits: agent_session →
    `agent-<session>`, principal → `<principal>`, classified writers
    disambiguated by display_name with an `openknowledge-service`
    fallback. Explicit writer_id in stored JSON wins.
  - `okActorToShadowContributor(a)` — thin adapter projecting onto the
    legacy DTO the UI + CLI consume. Keeps those rendering surfaces
    unchanged; rich actor data (agent_type, client_name, …) stays
    available for future adoption.
  - `readContributors(body)` — single dispatcher: prefers ok-actor,
    falls back to parseContributors. Transitional commits with BOTH
    lines prefer ok-actor (no double-counting).
  - `parseContributors` (legacy reader) preserved for the fallback path
    + for tests that synthesize legacy bodies. `formatContributors*` in
    contributor-tracker.ts is now production-dead but kept as a test
    utility for legacy-body generation.

server/persistence.ts
  - L2 drain per-writer loop: drop `ok-contributors:` inline emission,
    keep only `formatOkActor(actorEntry)` with writer_id + summaries
    populated.
  - Service-writer fallback path (zero-contributor drain): populate
    writer_id.

server/shadow-repo.ts
  - Populate writer_id on all four direct-OkActorEntry emit sites:
    commitUpstreamImport, safetyCheckpoint, parkBranch,
    saveInMemoryCheckpoint.

server/timeline-query.ts + cli/content/shadow-log.ts
  - Switch `parseContributors(rawBody)` → `readContributors(rawBody)`.
    One line each. Consumer DTO (`ShadowContributor`) unchanged; the
    UI + CLI render paths see identical data regardless of which body
    line format lives on disk.

core/shadow-repo-layout.test.ts
  - Update `baseEntry` / `sparse` fixtures with writer_id.
  - 22 new tests covering writer_id (explicit + 5 derivation paths),
    summaries round-trip on ok-actor (including malformed field-drop),
    `parseOkActors` (plural — valid, skip-malformed, empty), and
    `readContributors` dispatcher (modern-only, legacy-only, both-prefer-
    ok-actor, multi-writer).

`bun run check` green: 15/15 turbo tasks. All 58 summary-related tests
pass; shadow-repo + timeline-query + branch-gc tests pass in isolation
(flakes under full-suite concurrent bun invocation are pre-existing
timing sensitivity in the git-op-heavy tests, unrelated to this change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
miles-kt-inkeep added a commit that referenced this pull request Apr 23, 2026
…ine bullets (#268)

* [US-001] add summaries?: string[] to ShadowContributor + D27 field-level drop

Extends the shadow-log `ok-contributors:` JSON parser with an additive
`summaries?: string[]` field (spec D23 flat shape). On malformed values
(non-array or array with non-string elements) the parser drops just the
`summaries` field and leaves the rest of the contributor entry intact —
a deliberate divergence from the whole-entry-skip convention used for
other optional fields, because decorative loss (no bullets) beats
attribution loss (missing contributor).

No v-bump: additive per precedent #9 and D9. Legacy commits parse with
`summaries: undefined` and downstream consumers render unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [US-002] thread summary through contributor-tracker accumulator

- ContributorEntry gains `summaries: string[]` (always initialized, emitted
  only when non-empty so legacy commits stay byte-identical).
- `recordContributor` gains optional 5th `summary` arg; appends only when
  the summary is a non-empty string.
- `formatContributorsFrom` omits the `summaries` key unless populated — the
  D23 flat shape, additive per precedent #9 / spec D9.
- `restoreContributors` prepends snapshot summaries ahead of live arrivals
  so chronological order is preserved across a failed-then-retried commit
  (D16). No dedup: legitimate duplicates flow through.
- Unit tests cover undefined / empty-string / single / multi / mixed-contributor
  emission, plus a US-001 round-trip through `parseContributors` and all three
  restore pathways.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [US-003] normalizeSummary helper + M1/M2 counters + three agent-write handlers

- New `agent-write-summary.ts` exports `normalizeSummary` and a 3-state
  discriminated union (`absent`/`invalid`/`value`). 80-char API cap per
  D24 (79 visible + U+2026 ellipsis); `truncatedFrom` only set when the
  input actually exceeds the cap (D20).
- New counters: `agentWriteCalls` (M1 denominator), `summariesProvided`
  (M1 numerator), `summariesTruncated` (M2).
- Split helpers: `summaryResponseFields` (pure response-shape) and
  `countNormalizedSummary` (side-effects). Separation keeps counters off
  the 404/409 patch early-return path.
- `handleAgentWrite`, `handleAgentWriteMd`, `handleAgentPatch` each
  extract + validate, 400 on wrong-type, pass stored summary to
  `recordContributor` as the 5th arg, fire counters post-success,
  and append `summary: {value, truncatedFrom?}` + `hint` to the
  success response when the caller supplied a summary.
- Patch handler fires counters only when `\!notFound && \!staleTarget`
  (adoption rate reflects successful writes, not attempts).
- Unit tests: 12 helper cases including exact-80 no-truncation (D20),
  ellipsis-is-U+2026, and surrogate-pair emoji. Integration tests: 11
  HTTP-level scenarios across all three endpoints including the
  summary-404-does-not-increment regression for `/api/agent-patch`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [US-004] rename + rollback — D22 agentId-guarded attribution + summaries

Rename and rollback handlers now accept `summary` and record contributor
attribution, but ONLY when the body carries an explicit `agentId`. UI
paths (EditorPane.tsx:155 Restore button) that post without identity
stay anonymous — this is the D22 LOCKED 1-way door.

- `handleRollback` and `handleRename` each:
  - Guard: `hasAgentId = typeof bodyObj.agentId === 'string' && .length > 0`.
  - When `hasAgentId`, run `normalizeSummary` + respond 400 on wrong-type.
  - When `hasAgentId` + no user summary, substitute the spec default:
    - rollback: `"Restored to <sha-short>"` (first 8 chars).
    - rename:   `"Renamed <from> → <to>"`.
  - Default goes through the same `normalizeSummary` path — single
    truncation point per D5/D24.
  - Attribute only the PRIMARY doc (rolled-back / renamed-to). Side-effect
    docs rewritten by the rename backlink pass stay anonymous.
  - Fire `agentWriteCalls` + `summariesProvided` + `summariesTruncated`
    ONLY when attribution actually ran (UI paths do not inflate M1).
  - Response shape matches the three agent-write handlers:
    `summary: {value, truncatedFrom?}` + optional `hint` when truncated.

Regression tests lock the D22 invariant: no-agentId rename produces ZERO
contributor entries, file renames correctly, and no counter increments.
Wrong-type summary returns 400 BEFORE `_performManagedRename` runs so the
file stays put.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [US-005] MCP tools: summary param + rename/rollback identityRef passthrough

All four write-like MCP tools (`write_document`, `edit_document`,
`rename_document`, `rollback_to_version`) now:

- Accept optional `summary: string` with a 200-char Zod hard cap (D21
  transport-safety bound; API-side 80-char truncation is the rendering
  bound and lives in the server handlers).
- Describe user-outcome phrasing, the 80-char render cap, and (via FR15)
  a no-PII reminder — summaries are persisted to git history.
- Forward `summary` in the httpPost body when provided; absent otherwise.
- Surface the server's `summary` field on `structuredContent` and the
  `hint` text on the human-readable message body.
- Rename + rollback now accept `identityRef` in their deps (matching
  write-document's existing pattern) and spread `agentId`/`agentName`/
  `clientName`/`colorSeed` into the HTTP body. Without this D15 wire,
  MCP-driven rename/rollback would hit the server-side D22 guard and
  stay anonymous.
- Rename + rollback descriptions explicitly mention the default-
  substitution behavior per FR11 (`"Renamed X → Y"` and
  `"Restored to <sha-short>"`).

index.ts registers both rename + rollback with `identityRef: opts.identityRef`,
so the MCP server boot path is updated in one place.

17 new tests in `summary-passthrough.test.ts` exercise summary flow-through
on all four tools, identityRef passthrough on rename + rollback, the
description guardrails (FR11/FR15), and the 200-char Zod hard-cap behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [US-006] TimelinePanel: collapsible bullet rendering for agent summaries

Agent-provided summaries now render as a collapsible bullet list between
the author line and the doc-list line in EntryRow. First bullet shown
inline; further bullets behind a "Show N more" expander matching the
existing WipGroup chevron pattern. Doc-list ALWAYS renders alongside
per spec D16 — bullets enrich, the doc-list stays as ground truth.

- New pure helper `allSummariesFor(entry): string[]` flattens across
  contributors in contributor order (D23 flat shape). Exported for
  unit-test coverage.
- New `SummaryBullets` component with collapsed-by-default `useState`
  mirror of the WipGroup pattern. Default collapsed so coalesced-heavy
  rows don't dominate the panel.
- EntryRow converted from `<button>` to `<div role="button">` so the
  nested SummaryBullets expander can be a real `<button>` (native
  nested buttons are invalid HTML). tabIndex + Enter/Space keyboard
  activation preserve the prior semantics. One targeted
  biome-ignore for the role change, with a comment explaining why the
  structural swap is load-bearing.
- 5 new unit tests on `allSummariesFor` cover legacy (no summaries
  field), empty-contributors, single-contributor order preservation,
  multi-contributor flatten, and mixed-summary cases.

Browser verification deferred: existing Playwright E2E coverage
catches React rendering regressions; the pattern mirrors the proven
WipGroup design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [US-007] cross-cutting round-trip + CLI enrichment carry-through tests

Final vertical-slice proof for the agent-write-summaries feature.

- `packages/server/src/summary-e2e.test.ts` (new, 4 tests) exercises the
  full server-side chain skipping only the HTTP + MCP layers (already
  covered elsewhere):
    recordContributor → swapContributors → formatContributorsFrom →
    commitWip (real shadow repo) → getDocumentHistory → TimelineEntry
  Cases:
    - single contributor, multiple summaries, correct order
    - legacy body (no summaries) reads back as `summaries: undefined`
    - two contributors in one commit, each independent
    - summary-less + summaried contributors coexist cleanly
- `packages/cli/src/content/shadow-log.test.ts` gains 2 FR14 tests
  proving that `readShadowLog`'s enrichment path surfaces the new
  `summaries` field automatically via the existing `parseContributors`
  call at line 120 — no CLI code change required. Legacy-shape body also
  round-trips with `summaries: undefined`.

The D22 UI-Restore regression gate, the three agent-write counter
wiring, and the MCP identityRef passthrough already have their own
regression tests from US-004 / US-003 / US-005; the spec.json notes
field now points at all three.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* post-impl: whitespace-only summary → absent + explicit Zod cap test + array/whitespace negative paths

Three staff-engineer-review refinements surfaced during /ship Phase 3
post-implementation audit. All three fix real holes in the ship
without deferring to future work.

1. `normalizeSummary` treats whitespace-only strings as `absent` rather
   than forwarding a blank-bullet summary. An agent-supplied "   " or
   "\t\n" would otherwise render as an empty bullet in TimelinePanel
   and inflate the M1 adoption counter with zero user signal.
   Non-whitespace-only strings keep their surrounding whitespace
   verbatim — only the "entirely whitespace" case short-circuits. Spec
   §6 FR2 acceptance says "empty strings are treated as missing"; this
   extends the same semantic to the pathological case a strict byte-
   length check would miss.

2. `summary-passthrough.test.ts` — the existing "200-char summary
   passes Zod validation" test was misleading: `createCaptureServer`
   discarded the schema arg, so Zod was never invoked. Fixed by
   capturing the real schema object at `register()` time and exercising
   it directly via `safeParse` — now we actually prove the 200-char
   `z.string().max(200).optional()` transport-safety cap (D21) rejects
   201-char input AND non-string types (number, object, array) at the
   MCP layer, before any HTTP request goes out. The "passes through
   unchanged to HTTP body" assertion stays as a separate test with a
   name that matches what it actually covers.

3. Added two missing negative-path tests to `api-agent-write-summary.test.ts`:
   - `summary as JSON array → 400` — closes a gap where an array would
     hit `typeof raw !== 'string'` correctly but had no explicit
     coverage. Proves no auto-join (would-be tempting sharp edge).
   - `summary whitespace-only → treated as absent` — mirrors the
     agent-write-summary unit test's whitespace coverage at the HTTP
     tier, confirming the response has no `summary` field and no
     counter increments.
   Plus matching unit-test cases in `agent-write-summary.test.ts` for
   whitespace-only / surrounding-whitespace-preserved / array-wrong-type.

All changes are test-additive or contract-tightening — no behavior
regression for any previously valid input.

Total tests: 4134 pass, 0 fail. All 15 turbo tasks green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* spec: agent-write-summaries

Full approved spec (Andrew Mikofalvy, 2026-04-21) — adds optional
`summary: string` param to the four MCP write tools. Landing here on
the feature branch so the review + PR carry the spec alongside the
implementation; main will pick it up on merge.

27 LOCKED/DIRECTED decisions, 15 functional requirements, 12
non-goals, 10 open questions all closed. Supporting evidence:

- evidence/code-trace-existing-attribution-pipeline.md
- evidence/worldmodel-synthesis.md
- meta/_changelog.md (full process trail)
- meta/audit-findings.md
- meta/design-challenge.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: agent-write-summaries — timeline bullets, MCP summary param, internal attribution journal, STOP rules

- docs/content/guides/timeline.mdx — "Agent summaries" subsection explaining the collapsible bullet list rendering, 80-char cap, rename/rollback defaults, and the D22 human-Restore-stays-anonymous guarantee; row-types list notes the writer-chip row can carry bullets.
- docs/content/guides/mcp-integration.mdx — new "summary on write tools" callout covering the four MCP write tools, the 80 / 200-char cap split, rename/rollback defaults, MCP-only attribution, and the no-PII/secrets nudge.
- docs/content/internals/agent-write-path.mdx — "Attribution journal (summaries)" section: flat `summaries: string[]` wire shape, single-source-of-truth truncation at normalizeSummary, D22 agentId-guard for rename/rollback, default summaries, parser field-level drop-malformed policy (D27), metrics counters.
- AGENTS.md — key-files list gains contributor-tracker.ts + agent-write-summary.ts; metrics.ts line documents the three new M1/M2 counters; two new STOP rules: (1) all summary normalization/truncation must flow through normalizeSummary, and (2) rename/rollback handlers must guard recordContributor on explicit agentId per D22 LOCKED.
- .changeset/agent-write-summaries.md — minor across core/server/cli/app.

Full SPEC: specs/2026-04-21-agent-write-summaries/SPEC.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fixup! local-review: address findings (pass 1)

* qa-fix: drain pendingContributors on rollback + rename (BUG-1 from Phase 7)

/qa surfaced a P1 bug across QA-006, QA-007, QA-012, and coalesce tests:
handleRollback and handleRename recorded summary attribution into
`pendingContributors`, but neither actually drained it into its own L2
shadow commit. The pending entry then leaked into the NEXT unrelated
write's commit — polluting that commit's `ok-contributors:` line with
stale "Restored to <sha>" / "Renamed X → Y" bullets and stale docs.

Root cause — rollback:
  handleRollback called `setReconciledBase(docName, markdown)` right
  after the `document.transact(...)` but BEFORE `onStoreDocument` fired
  (Hocuspocus debounces ~2s by default). When onStoreDocument later
  fired, the "skip write when serialized === currentBase" guard at
  `persistence.ts:onStoreDocument:412` found them identical and
  returned without writing disk — which also skipped the
  `scheduleGitCommit()` call. The raw `flushGitCommit()` the handler
  then invoked was a no-op (no timer was set). So: the rolled-back
  content never hit disk (data loss on restart) AND the pending
  contributor entry orphaned until the next write scheduled a
  commit.

Root cause — rename:
  `_performManagedRename` writes files via synchronous fs APIs
  (`renameSync`, `syncRenamedDocsToDisk`) that bypass
  `onStoreDocument` entirely, so `scheduleGitCommit` never fired
  from that path. The handler added the contributor entry but did
  not explicitly flush. Same orphan-into-next-commit shape.

Fix:
  - Rollback: remove the premature `setReconciledBase(...)` call.
    onStoreDocument's own post-write `setReconciledBase` at
    persistence.ts:497 is the correct point — it fires AFTER the
    atomic tmp+rename, so the base reflects what's actually on
    disk. Replace the raw `flushGitCommit()` with
    `flushDocToGit(docName, 'rollback')`, which uses Hocuspocus's
    `debouncer.executeNow` to force L1 synchronously, then
    flushes L2 — matching the pattern already used by all three
    agent-write handlers (`handleAgentWrite`, `handleAgentWriteMd`,
    `handleAgentPatch`).
  - Rename: add `flushDocToGit(newDocName, 'rename')` after
    `recordContributor`. `_performManagedRename` closes the
    source doc (which fires `onStoreDocument → scheduleGitCommit`
    on the source side), so the L2 timer is already set; the
    explicit kick drains it with the now-populated contributor
    entry rather than letting it coalesce into the next unrelated
    write.

Both fixes are inside the spec's §13 SCOPE (api-extension.ts) —
persistence.ts is untouched per spec §13 EXCLUDE.

Tests (regression gate, `api-rename-rollback-summary.test.ts`):
  - Rename WITH agentId → flushGitCommit fires ≥1 times
  - Rename WITHOUT agentId → flushGitCommit NOT called (UI path
    stays as lean as pre-feature; D22 preserved)
  - Rename WITH wrong-type summary (400 early-return) →
    flushGitCommit NOT called (no side-effects before validation)
  - Minimal debouncer stub added to the mock Hocuspocus so the
    harness exercises `flushDocToGit`'s guarded path (real
    debouncer behavior covered by summary-e2e.test.ts).

Quality gate: 15/15 turbo tasks green (unit + integration +
conversion + fidelity).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fixup! local-review: address findings (pass 1)

* fixup! local-review: advisory pass (pass 2)

* rebase-repair: fix summaryResponseFields destructure after foundation merge

During the rebase onto origin/main (post-#222 foundation), the merge
resolution in three api-extension.ts call sites incorrectly destructured a
non-existent top-level `hint` field from `summaryResponseFields(normalized)`.
The function returns `{response?: SummaryResponse; stored}` with `hint`
nested inside `response`. Since neither of the three response bodies
actually emits a top-level `hint` key anymore (the nested `summaryResponse.hint`
is the single source of truth that ships to the caller), the `summaryHint`
variable was dead in all three sites. Drop the declarations.

Sites:
- handleAgentWriteMd — write path JSON response
- handleRollback — D22-guarded response
- handleRename — D22-guarded response

Typecheck + lint green across core + server + cli.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(summaries): embed single-summary writes in commit subject (FR14)

Lands the first of the additions from the sibling-spec PR #277 analysis:
subject-line projection of `ContributorEntry.summaries`, so
`git log --oneline refs/wip/main/agent-<connId>` reads like a scannable
team feed instead of a wall of duplicate `wip: notes.md` subjects. The
existing body bullets (D23 `ok-contributors.summaries` + TimelinePanel UI)
stay intact — this is purely additive on the subject axis.

core/shadow-repo-layout.ts
  - New pure helper `composeCommitSubject(base, summaries)` + exported
    `COMMIT_SUBJECT_MAX_LEN = 72` (CommonMark subject-line convention).
    Rules:
      0 summaries → base unchanged (pre-feature byte-identity).
      1 summary  → `<base> — <summary>` truncated with U+2026 (matches
                   normalizeSummary's ellipsis convention); base never
                   truncated.
      ≥2 summaries → `<base> (N edits)` — body carries the bullet list.
  - 9 new unit tests covering 0/1/≥2 rules, 72-char boundary, oversize
    truncation, non-wip prefixes (rename:, rollback:, reconcile:), and
    the defensive over-budget-base case.

server/persistence.ts
  - L2 drain composes subject via `composeCommitSubject(baseSubject,
    [...entry.summaries])` on the per-writer fan-out path. Zero-summary
    writers continue to emit the exact pre-feature subject — no
    regression path for legacy commits or summary-less agent writes.
  - Keeps the existing `entry.subjectOverride ?? formatWipSubject(docs)`
    base-subject resolution so subject-prefix scheme (D53: wip:,
    reconcile:, rollback:, rename:, park:, etc.) composes cleanly with
    summary projection.

Rebase-repair (separate concern, bundled here):
  - Update contributor-tracker tests to the post-foundation 7-arg
    `recordContributor` signature (docName, writerId, displayName,
    colorSeed, subjectOverride, actor, summary) — the inserted
    `undefined, undefined` in slots 5-6 preserves prior test intent.
  - Same signature update for summary-e2e tests.
  - Update api-agent-write-summary agent-patch tests to the post-foundation
    `session.dc.document.transact(fn, session.origin)` pattern (was using
    the pre-foundation `dc.document.transact(fn, AGENT_WRITE_ORIGIN)`
    which dropped per-session origin attribution — precedent #24 STOP).
  - Drop unused `AGENT_WRITE_ORIGIN` import from the test file.

`bun run check` green: 15/15 turbo tasks, 204 integration + 941 server
unit + all fidelity / conversion / core tiers. No bridge-matrix or
attribution-sweep regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(history): consolidate attribution to ok-actor, retire ok-contributors emit

Completes the foundation team's deferred read-path migration (SPEC
2026-04-18, FR-8 / D13). Before this change the shadow-repo commit body
carried BOTH `ok-contributors:` (from attribution PR #134, 2026-04-15)
AND `ok-actor:` (from foundation PR #222, 2026-04-21). The foundation
landed `ok-actor:` + formatOkActor + parseOkActor as the canonical
structured-tuple body line, but left `ok-contributors:` in the write
path because both live readers (timeline-query.ts, shadow-log.ts) still
used parseContributors. Result: parseOkActor had zero production
callers on main until this commit — it was write-only.

This commit retires `ok-contributors:` from the write path in one atomic
schema migration and wires the two live readers through a new dispatcher
that prefers ok-actor and falls back to ok-contributors for legacy
on-disk commits (greenfield directive waives on-disk back-compat, but
keeping the dispatcher means git-log on pre-migration commits stays
legible for $0 extra maintenance cost).

core/shadow-repo-layout.ts
  - `OkActorEntry` gains `writer_id: string` (the ref-name — the thing
    ok-contributors.id carried pre-consolidation). Makes commit bodies
    self-describing: `git show -s <sha>` → full attribution without a
    join against `git for-each-ref`. Also disambiguates classified
    writers, which otherwise share `{principal: null, agent_session: null}`.
  - `OkActorEntry.summaries?: string[]` — moves from ok-contributors to
    ok-actor as part of the migration. Emitted only when non-empty
    (legacy byte-identity for summary-less writes). Malformed parse drops
    just the field (D27 divergence).
  - `parseOkActors(body): OkActorEntry[]` — plural. FR-7 per-writer
    fan-out emits one ok-actor per writer in multi-contributor drains.
  - `parseOkActor` gains back-compat derivation for `writer_id` when
    reading pre-consolidation ok-actor commits: agent_session →
    `agent-<session>`, principal → `<principal>`, classified writers
    disambiguated by display_name with an `openknowledge-service`
    fallback. Explicit writer_id in stored JSON wins.
  - `okActorToShadowContributor(a)` — thin adapter projecting onto the
    legacy DTO the UI + CLI consume. Keeps those rendering surfaces
    unchanged; rich actor data (agent_type, client_name, …) stays
    available for future adoption.
  - `readContributors(body)` — single dispatcher: prefers ok-actor,
    falls back to parseContributors. Transitional commits with BOTH
    lines prefer ok-actor (no double-counting).
  - `parseContributors` (legacy reader) preserved for the fallback path
    + for tests that synthesize legacy bodies. `formatContributors*` in
    contributor-tracker.ts is now production-dead but kept as a test
    utility for legacy-body generation.

server/persistence.ts
  - L2 drain per-writer loop: drop `ok-contributors:` inline emission,
    keep only `formatOkActor(actorEntry)` with writer_id + summaries
    populated.
  - Service-writer fallback path (zero-contributor drain): populate
    writer_id.

server/shadow-repo.ts
  - Populate writer_id on all four direct-OkActorEntry emit sites:
    commitUpstreamImport, safetyCheckpoint, parkBranch,
    saveInMemoryCheckpoint.

server/timeline-query.ts + cli/content/shadow-log.ts
  - Switch `parseContributors(rawBody)` → `readContributors(rawBody)`.
    One line each. Consumer DTO (`ShadowContributor`) unchanged; the
    UI + CLI render paths see identical data regardless of which body
    line format lives on disk.

core/shadow-repo-layout.test.ts
  - Update `baseEntry` / `sparse` fixtures with writer_id.
  - 22 new tests covering writer_id (explicit + 5 derivation paths),
    summaries round-trip on ok-actor (including malformed field-drop),
    `parseOkActors` (plural — valid, skip-malformed, empty), and
    `readContributors` dispatcher (modern-only, legacy-only, both-prefer-
    ok-actor, multi-writer).

`bun run check` green: 15/15 turbo tasks. All 58 summary-related tests
pass; shadow-repo + timeline-query + branch-gc tests pass in isolation
(flakes under full-suite concurrent bun invocation are pre-existing
timing sensitivity in the git-op-heavy tests, unrelated to this change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: miles-kt-inkeep <miles.kamingthanassi@inkeep.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants