Files
podman/docs/plans/work-history-roi.md
Ramis 8d17663734 docs: add Work History coordination-ROI build plan
Build-ready spec for adding a "rework saved" ROI band to the member
Work History dialog: transparent heuristic over caught collisions,
additive shared field + backend queries + presentational component.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuV8W8oNYRsDWKoqK8Mkqc
2026-06-28 07:46:47 -07:00

13 KiB
Raw Permalink Blame History

Build spec: Work History "Coordination ROI" band

Self-contained spec for an implementing agent. Everything needed to build is here: current-state anchors, exact edits, code sketches, verification. Read the referenced files before editing. Changes are additive — one optional field on a shared type, two new backend queries, one new presentational component. No route signature changes, no DB writes, no new deps.

Context / why

The Work History dialog (opens when you click a teammate's "History" button) today shows three raw counters (files / screen logs / git changes), a Recent files bar list, and a Timeline. It is pure activity volume — it summarizes nothing and shows none of PodMan's actual value.

We add a Coordination ROI band at the top of the dialog that answers "what did PodMan save this person?": estimated rework-hours saved by clashes Hermes caught early, plus hard defensible counts. This is a summary layer — it must NOT re-stream the pod activity feed ("Team Memory"). Existing Recent files + Timeline sections stay exactly as they are, below the new band.

Locked decisions

  • Primary story = saved time / ROI. One number leads: ~Xh Ym rework saved.
  • Transparent heuristic. The hours are a labeled estimate (~, (est.)) with an info tooltip showing the per-clash breakdown. Technical judges can see the model; we never claim precision.
  • Backend extension required (collisions/interventions are not in the current MemberWorkHistory payload). Field is optional → old frontends and pods with no collisions degrade gracefully (band hidden).
  • Cut: editing/research focus donut. ROI is the single story; a donut dilutes it.
  • Credit split across involved engineers so a 2-person clash does not double-count the pod total.

Heuristic (this is the contract — implement exactly)

For each collisions doc in the window where the member is involved AND an interventions doc exists for it (Hermes actually surfaced it):

  1. Eligibility (must be "real"): count the collision only if gitOverlap === true OR severity === 'critical'. Otherwise skip.
  2. Weight by kind/severity:
    condition minutesEach
    overlapKind === 'research' 10
    severity === 'critical' (same-file) 45
    severity === 'warn' (same-file) 20
    severity === 'info' (same-file) 10
    (Evaluate research first; a research overlap is always 10 regardless of severity.)
  3. Member share: minutesEach / max(1, engineers.length). Sum all shares → savedMinutes (round to nearest integer).
  4. breakdown[]: group eligible collisions by label ("critical same-file", "warn same-file", "research overlap", etc.), each entry { label, count, minutesEach } — for the tooltip.

Member "involved" = engineers array contains the member (case-insensitive), OR researcher/editor equals the member (case-insensitive). Reuse the existing sameMember() helper logic.

Hard counts (no estimation):

  • clashesCaught = number of eligible collisions (the integer behind the hours).
  • filesDeconflicted = distinct collision.file values across eligible collisions.
  • conflictFreeCommits / totalCommits: see "commits" note below.

Commits note (keep simple, defensible)

There is no per-commit log in the window. Use the git ground-truth we have: totalCommits = count of distinct changedFiles for the member from engineer_states (already loaded as gitState.changedFiles.length). conflictFreeCommits = totalCommits - filesDeconflicted (clamped ≥ 0). This reads as "files in flight that never hit a clash". Label it in the UI as "conflict-free files", not commits, so the wording matches the data. (The band copy below already says "files".)

Current-state anchors (read these first)

  • shared/src/member-history.ts:24-36MemberWorkHistory interface (add roi?).
  • backend/src/activity/member-history.ts:73-174getMemberWorkHistory().
    • :83-94 — the Promise.all that loads observations + engineer_states. Add collisions/interventions here.
    • :45-47sameMember() helper to reuse for involvement check.
    • :124gitState already in scope; :169 uses gitState.changedFiles.length.
    • :161-173 — the returned object (add roi).
  • backend/src/memory/db.ts:47-48 — canonical collection names collisions, interventions (typed Collision, Intervention).
  • backend/src/activity/store.ts:167-178 — reference query shape for both collections.
  • shared/src/collision.ts:7-36Collision (engineers, severity, overlapKind, gitOverlap, researcher, editor, file, detectedAt).
  • shared/src/intervention.ts:8-18Intervention (collisionId, podId).
  • frontend/src/components/PodView.tsx:33-35 — type imports from @podman/shared.
  • frontend/src/components/PodView.tsx:1035-1041 — the history && !loading && !error block; the 3-stat grid is the insert point (band goes ABOVE it).
  • frontend/src/components/PodView.tsx:1093-1100HistoryStat (style reference for new sub-stats).
  • frontend/src/components/PodView.tsxtimeLabel() exists for relative time; reuse if needed.
  • Route is unchanged: backend/src/server.ts:502-506 already returns whatever getMemberWorkHistory produces.

Edit 1 — shared type (shared/src/member-history.ts)

Add to the MemberWorkHistory interface (after timeline):

  /** Coordination ROI summary — clashes Hermes caught for this member. Optional
   * so pods with no collisions / older payloads render without the band. */
  roi?: MemberWorkHistoryRoi;
}

export interface MemberWorkHistoryRoi {
  /** Estimated rework minutes saved (heuristic, labeled "~/est." in UI). */
  savedMinutes: number;
  /** Eligible collisions caught early (hard count). */
  clashesCaught: number;
  /** Distinct files that hit an eligible clash. */
  filesDeconflicted: number;
  /** Member files in flight that never hit a clash. */
  conflictFreeFiles: number;
  /** Total member files in flight (git changedFiles). */
  totalFiles: number;
  /** Per-kind breakdown for the tooltip. */
  breakdown: { label: string; count: number; minutesEach: number }[];
}

shared/src/index.ts:56-59 already re-exports the member-history types via a export type { ... } block — add MemberWorkHistoryRoi to that list.

Edit 2 — backend (backend/src/activity/member-history.ts)

  1. Import the types: import type { Collision } from '@podman/shared'; (Intervention only needs collisionId, can stay untyped or import too).
  2. In the Promise.all at :83, add two queries (windowed, podId-scoped):
db.collection<Collision>('collisions')
  .find({ podId, detectedAt: { $gte: since } }, { projection: { _id: 0 } })
  .sort({ detectedAt: -1 })
  .limit(200)
  .toArray(),
db.collection<{ collisionId: string }>('interventions')
  .find({ podId }, { projection: { collisionId: 1, _id: 0 } })
  .toArray(),
  1. After building fileRows, compute roi with a new local helper computeRoi(member, collisions, interventions, gitState):
function computeRoi(
  member: string,
  collisions: Collision[],
  interventionCollisionIds: Set<string>,
  changedFileCount: number,
): MemberWorkHistoryRoi {
  const involved = (c: Collision) =>
    c.engineers?.some((e) => sameMember(e, member)) ||
    sameMember(c.researcher, member) ||
    sameMember(c.editor, member);

  const eligible = collisions.filter(
    (c) =>
      involved(c) &&
      interventionCollisionIds.has(c.id) &&
      (c.gitOverlap === true || c.severity === 'critical'),
  );

  const weightOf = (c: Collision): { label: string; minutes: number } => {
    if (c.overlapKind === 'research') return { label: 'research overlap', minutes: 10 };
    if (c.severity === 'critical') return { label: 'critical same-file', minutes: 45 };
    if (c.severity === 'warn') return { label: 'warn same-file', minutes: 20 };
    return { label: 'info same-file', minutes: 10 };
  };

  let savedMinutes = 0;
  const groups = new Map<string, { count: number; minutesEach: number }>();
  for (const c of eligible) {
    const { label, minutes } = weightOf(c);
    savedMinutes += minutes / Math.max(1, c.engineers?.length ?? 1);
    const g = groups.get(label) ?? { count: 0, minutesEach: minutes };
    g.count += 1;
    groups.set(label, g);
  }

  const filesDeconflicted = new Set(eligible.map((c) => c.file)).size;
  const totalFiles = changedFileCount;
  return {
    savedMinutes: Math.round(savedMinutes),
    clashesCaught: eligible.length,
    filesDeconflicted,
    conflictFreeFiles: Math.max(0, totalFiles - filesDeconflicted),
    totalFiles,
    breakdown: [...groups.entries()].map(([label, g]) => ({
      label,
      count: g.count,
      minutesEach: g.minutesEach,
    })),
  };
}
  1. Build the intervention id set and attach to the return:
const interventionIds = new Set(interventions.map((i) => i.collisionId));
const roi = computeRoi(member, collisions, interventionIds, gitState?.changedFiles?.length ?? 0);

Add roi to the returned object at :161-173. Always return it (it self-zeroes when there are no clashes); the frontend decides whether to show the band based on clashesCaught/savedMinutes.

Note Collision.id survives the { _id: 0 } projection — id is a real field (shared/src/collision.ts:8), distinct from Mongo _id. Do not project it out.

Edit 3 — frontend (frontend/src/components/PodView.tsx)

  1. Add MemberWorkHistoryRoi to the @podman/shared type import (:33-35).
  2. Insert <RoiBand roi={history.roi} /> immediately inside the history && !loading && !error block, BEFORE the grid ... sm:grid-cols-3 stat grid (:1037).
  3. New presentational component (place near HistoryStat, :1093):
function formatSaved(minutes: number): string {
  if (minutes < 60) return `~${minutes}m`;
  const h = Math.floor(minutes / 60);
  const m = minutes % 60;
  return m ? `~${h}h ${m}m` : `~${h}h`;
}

function RoiBand({ roi }: { roi?: MemberWorkHistoryRoi }) {
  if (!roi || roi.clashesCaught === 0) return null; // no clashes → no band
  const conflictFree = roi.totalFiles
    ? Math.round((roi.conflictFreeFiles / roi.totalFiles) * 100)
    : 100;
  return (
    <section className="rounded-lg border bg-primary/5 p-4">
      <div className="flex flex-wrap items-end justify-between gap-3">
        <div>
          <div className="flex items-center gap-1.5">
            <p className="font-mono text-2xl font-semibold">{formatSaved(roi.savedMinutes)}</p>
            <span className="text-sm text-muted-foreground">rework saved</span>
            <RoiTooltip roi={roi} />
          </div>
          <p className="mt-0.5 text-xs text-muted-foreground">
            estimated · clashes caught pre-commit
          </p>
        </div>
        <div className="text-right">
          <p className="font-mono text-lg font-medium">{roi.clashesCaught}</p>
          <p className="text-xs text-muted-foreground">clashes caught early</p>
        </div>
      </div>
      <div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
        <div className="h-full rounded-full bg-primary" style={{ width: `${conflictFree}%` }} />
      </div>
      <p className="mt-1.5 text-[0.68rem] text-muted-foreground">
        conflict-free: {roi.conflictFreeFiles} of {roi.totalFiles} files ·{' '}
        {roi.filesDeconflicted} auto-deconflicted
      </p>
    </section>
  );
}
  1. Tooltip — reuse the existing Tooltip primitives already imported in this file (Tooltip, TooltipTrigger, TooltipContent — see the History button at :982-988). RoiTooltip renders an /InfoIcon trigger; content lists breakdown rows as {count} × {minutesEach}m {label} plus a footer line "split across engineers · est. only". Keep it a few lines.

Verification

pnpm -r build            # @podman/shared first, then backend + frontend typecheck

Manual / demo check (against a pod that has collisions, e.g. demo-pod):

  1. Open the app, click a teammate who was in a clash → History.
  2. Band shows at top: ~Xh Ym rework saved, clashes-caught integer, conflict-free bar.
  3. Hover → breakdown rows match the heuristic table.
  4. Click a teammate with no clashes → band absent, rest of dialog unchanged.
  5. Existing Recent files + Timeline sections render unchanged below the band.

Quick data sanity (optional, on the box or via mongosh):

db.collisions.find({ podId: "demo-pod" }).count()       // > 0 for a real band
db.interventions.find({ podId: "demo-pod" }).count()    // surfaced clashes

Coordination / merge risk (team is concurrent)

  • Touches two shared hot files: shared/src/member-history.ts and frontend/src/components/PodView.tsx. Both edits are additive (one optional field + one new component + one insert line). Low conflict risk, but announce before pushing.
  • git pull --rebase origin main immediately before push. Never force-push main.
  • No changes to /api/... route signatures, no new deps, no Mongo writes.

Deploy (after merge, on the box — see CLAUDE.md ops)

cd /root/podman && git pull && pnpm -r build
rm -rf /var/www/podman/* && cp -r frontend/dist/* /var/www/podman/
systemctl restart podman-platform-api podman-platform-agent

(Frontend-visible change + backend payload change → both the static build and the API service must be refreshed.)