Octopus Recency-Aware, Doc-Skeptical Repo Analysis Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Give Octopus’s repo analyzer, summarizer, and review-context retrieval a real file-recency signal and doc-vs-code discrimination so stale design docs stop polluting analyses and reviews.
Architecture: Three stacked fork branches on seanb4t/octopus (per ADR hl-78b):
(1) feat/file-recency-metadata adds an env-gated (INDEX_FILE_RECENCY) bare blob-less
git history scan at index time, writing a lastModifiedAt payload field per chunk;
(2) feat/doc-aware-analysis replaces the blind Qdrant scroll with structured selection
(doc cap, path diversity, recency preference) and adds a ## Documentation Accuracy
analysis section; (3) feat/recency-aware-review-context annotates and demotes stale
doc chunks in both review-context assemblies. A final cluster PR repoints digests and
enables the env.
Tech Stack: TypeScript (Next.js app dir), Bun (bun test), Qdrant JS client,
git CLI (already in the runner image), Kubernetes/ArgoCD (cluster rollout).
Spec: docs/engineering/specs/2026-06-07-octopus-doc-aware-analysis-design.md
Design bead: hl-9d8 · Epic context: hl-0we
Two repositories, two VCS disciplines
Section titled “Two repositories, two VCS disciplines”| Repo | Path | VCS | Rules |
|---|---|---|---|
| Fork (Tasks 1–12) | /Volumes/Code/github.com/seanb4t/octopus | plain git — NOT jj | Append # jj-exempt to every mutating git command (a PreToolUse guard in the cluster repo context blocks bare git; the fork is exempt) |
| Cluster (Tasks 13–14) | /Volumes/Code/github.com/fzymgc-house/selfhosted-cluster | jj colocated | Create a dedicated jj workspace add workspace; never work in the default workspace; PR to main (branch protection — user merges) |
Fork conventions: stacked branches, one upstream-PR-sized feature each. Current stack
tip is feat/optional-review-branding. Branch 1 stacks on the tip; branch 2 on
branch 1; branch 3 on branch 2. The tip branch’s CI build produces the digests the
cluster deploys.
Run all fork test/typecheck commands from apps/web/:
cd /Volumes/Code/github.com/seanb4t/octopus/apps/webbun test # all unit testsbun run typecheck # tsc --noEmitTask 1: Branch feat/file-recency-metadata + CI trigger list
Section titled “Task 1: Branch feat/file-recency-metadata + CI trigger list”Files:
- Modify:
.github/workflows/build-image.yaml:10(fork)
Steps:
- Step 1: Create the branch from the current stack tip
cd /Volumes/Code/github.com/seanb4t/octopusgit fetch origin # jj-exemptgit checkout -b feat/file-recency-metadata origin/feat/optional-review-branding # jj-exempt- Step 2: Add all three new branches to the CI trigger list
GitHub evaluates push.branches against the workflow file on the pushed ref, and the
stack inherits this edit, so add all three names once here. In
.github/workflows/build-image.yaml line 10, change:
branches: [feat/configurable-embedding-dim, feat/generic-oidc-auth, feat/configurable-embedding-batch, feat/optional-review-branding, master]to:
branches: [feat/configurable-embedding-dim, feat/generic-oidc-auth, feat/configurable-embedding-batch, feat/optional-review-branding, feat/file-recency-metadata, feat/doc-aware-analysis, feat/recency-aware-review-context, master]- Step 3: Commit
git add .github/workflows/build-image.yaml # jj-exemptgit commit -m "ci: build feat/file-recency-metadata + doc-aware stack branches" # jj-exemptTask 2: parseGitLogNameStatus (pure git-log parser)
Section titled “Task 2: parseGitLogNameStatus (pure git-log parser)”Files:
- Modify:
apps/web/lib/indexer.ts(new export, place nearshouldIndexaround line 49) - Test:
apps/web/lib/__tests__/file-recency.test.ts(create)
Steps:
- Step 1: Write the failing tests
Create apps/web/lib/__tests__/file-recency.test.ts. Importing @/lib/indexer pulls
qdrant/embeddings/provider modules, so copy the mock.module preamble pattern from the
existing apps/web/lib/__tests__/incremental-index.test.ts (the mocks for
@/lib/qdrant, @/lib/embeddings, @/lib/sparse-vector, and the provider libs —
exact same blocks), then add:
import { parseGitLogNameStatus } from "@/lib/indexer";
describe("parseGitLogNameStatus", () => { it("maps each path to its newest commit date (log is newest-first)", () => { const log = [ "COMMIT:2026-06-01T10:00:00+00:00", "M\tsrc/a.ts", "A\tdocs/design.md", "", "COMMIT:2026-01-15T10:00:00+00:00", "M\tsrc/a.ts", "A\tsrc/b.ts", ].join("\n"); const map = parseGitLogNameStatus(log); expect(map.get("src/a.ts")).toBe("2026-06-01T10:00:00+00:00"); expect(map.get("docs/design.md")).toBe("2026-06-01T10:00:00+00:00"); expect(map.get("src/b.ts")).toBe("2026-01-15T10:00:00+00:00"); });
it("records the NEW path for renames and copies", () => { const log = [ "COMMIT:2026-03-01T00:00:00+00:00", "R100\told/name.ts\tnew/name.ts", "C75\tsrc/base.ts\tsrc/copy.ts", ].join("\n"); const map = parseGitLogNameStatus(log); expect(map.get("new/name.ts")).toBe("2026-03-01T00:00:00+00:00"); expect(map.get("src/copy.ts")).toBe("2026-03-01T00:00:00+00:00"); expect(map.has("old/name.ts")).toBe(false); });
it("tolerates merge commits (no file lines) and blank lines", () => { const log = [ "COMMIT:2026-04-01T00:00:00+00:00", "", "COMMIT:2026-03-01T00:00:00+00:00", "M\tsrc/a.ts", ].join("\n"); expect(parseGitLogNameStatus(log).get("src/a.ts")).toBe("2026-03-01T00:00:00+00:00"); });
it("returns an empty map for empty input", () => { expect(parseGitLogNameStatus("").size).toBe(0); });});- Step 2: Run tests to verify they fail
cd /Volumes/Code/github.com/seanb4t/octopus/apps/webbun test lib/__tests__/file-recency.test.tsExpected: FAIL — parseGitLogNameStatus is not exported.
- Step 3: Implement the parser in
indexer.ts
Add after the shouldIndex function (~line 60):
// Parses `git log --format=COMMIT:%cI --name-status` output into a map of// path -> last-commit ISO date. The log is newest-first, so the first// occurrence of a path wins. Rename/copy lines (R<score>/C<score>\told\tnew)// record the NEW path — the old one no longer exists in the tree.export function parseGitLogNameStatus(log: string): Map<string, string> { const map = new Map<string, string>(); let currentDate: string | null = null; for (const line of log.split("\n")) { if (line.startsWith("COMMIT:")) { currentDate = line.slice("COMMIT:".length).trim(); continue; } if (!line || !currentDate) continue; const parts = line.split("\t"); if (parts.length < 2) continue; const status = parts[0]; const path = status.startsWith("R") || status.startsWith("C") ? parts[2] : parts[1]; if (!path) continue; if (!map.has(path)) map.set(path, currentDate); } return map;}- Step 4: Run tests to verify they pass
bun test lib/__tests__/file-recency.test.tsExpected: PASS (4 tests).
- Step 5: Commit
cd /Volumes/Code/github.com/seanb4t/octopusgit add apps/web/lib/indexer.ts apps/web/lib/__tests__/file-recency.test.ts # jj-exemptgit commit -m "feat(indexing): git-log name-status parser for file recency" # jj-exemptTask 3: collectFileRecency (bare blob-less history scan)
Section titled “Task 3: collectFileRecency (bare blob-less history scan)”Files:
- Modify:
apps/web/lib/indexer.ts(new export, directly belowparseGitLogNameStatus) - Test:
apps/web/lib/__tests__/file-recency.test.ts(extend)
Steps:
- Step 1: Write the failing integration test
This test builds a real throwaway git repo with controlled commit dates and scans it
over file://. Append to file-recency.test.ts:
import { collectFileRecency } from "@/lib/indexer";import { mkdtemp, rm as rmDir, writeFile, mkdir } from "node:fs/promises";import { tmpdir } from "node:os";import { join } from "node:path";import { execFile } from "node:child_process";import { promisify } from "node:util";
const run = promisify(execFile);
describe("collectFileRecency", () => { let repoDir: string;
beforeEach(async () => { repoDir = await mkdtemp(join(tmpdir(), "recency-fixture-")); const git = (args: string[], date?: string) => run("git", ["-C", repoDir, ...args], { env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t", ...(date ? { GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date } : {}), }, }); await run("git", ["init", "-b", "main", repoDir]); // file:// partial clones need allowFilter on the source await git(["config", "uploadpack.allowFilter", "true"]); await mkdir(join(repoDir, "docs"), { recursive: true }); await writeFile(join(repoDir, "docs/design.md"), "old doc\n"); await git(["add", "."], undefined); await git(["commit", "-m", "old"], "2024-01-01T00:00:00Z"); await writeFile(join(repoDir, "main.ts"), "new code\n"); await git(["add", "."], undefined); await git(["commit", "-m", "new"], "2026-06-01T00:00:00Z"); });
it("dates each file by its last commit", async () => { const map = await collectFileRecency(`file://${repoDir}`, undefined, "main", () => {}); expect(map.get("main.ts")?.startsWith("2026-06-01")).toBe(true); expect(map.get("docs/design.md")?.startsWith("2024-01-01")).toBe(true); await rmDir(repoDir, { recursive: true, force: true }); });
it("returns an empty map on clone failure instead of throwing", async () => { const map = await collectFileRecency("file:///nonexistent-repo", undefined, "main", () => {}); expect(map.size).toBe(0); await rmDir(repoDir, { recursive: true, force: true }); });});- Step 2: Run tests to verify they fail
bun test lib/__tests__/file-recency.test.tsExpected: FAIL — collectFileRecency is not exported.
- Step 3: Implement
collectFileRecencyinindexer.ts
Add directly below parseGitLogNameStatus. Auth follows the codebase convention:
never embed tokens in clone URLs (see the comment at indexer.ts:191–196) — pass
an Authorization header via GIT_CONFIG_* env exactly like the existing content
clone does.
const RECENCY_CLONE_TIMEOUT_MS = 120_000;
/** * Builds a path -> last-commit-ISO-date map via a bare, blob-less clone * (commits + trees only; cost scales with history size, not repo size). * Returns an EMPTY map on any failure — the index run must never fail or * block because of the recency scan. Paths absent from the map mean * "age unknown"; the payload step writes lastModifiedAt: null for them. */export async function collectFileRecency( cloneUrl: string, authHeader: string | undefined, branch: string, onLog: OnLog, signal?: AbortSignal,): Promise<Map<string, string>> { const scanDir = join(tmpdir(), `octopus-recency-${Date.now()}`); const args = ["clone", "--bare", "--filter=blob:none", "--single-branch", "--branch", branch]; const window = process.env.INDEX_FILE_RECENCY_WINDOW; if (window) args.push(`--shallow-since=${window}`); args.push(cloneUrl, scanDir); try { const controller = new AbortController(); if (signal) { signal.addEventListener("abort", () => controller.abort(), { once: true }); } await execFileAsync("git", args, { timeout: RECENCY_CLONE_TIMEOUT_MS, signal: controller.signal, env: { ...process.env, ...(authHeader ? { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "http.extraHeader", GIT_CONFIG_VALUE_0: `Authorization: ${authHeader}`, } : {}), }, }); const { stdout } = await execFileAsync( "git", ["-C", scanDir, "log", "--format=COMMIT:%cI", "--name-status"], { timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }, ); const map = parseGitLogNameStatus(stdout); onLog(`File-recency scan dated ${map.size} paths`, "success"); return map; } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.warn(`[indexer] File-recency scan failed (continuing without): ${msg}`); onLog("File-recency scan failed — indexing continues without file dates", "warning"); return new Map(); } finally { try { await rm(scanDir, { recursive: true, force: true }); } catch { console.warn(`[indexer] Failed to clean up recency scan dir: ${scanDir}`); } }}(join, tmpdir, rm, execFileAsync, and OnLog are already imported/defined in
indexer.ts — no new imports needed.)
- Step 4: Run tests to verify they pass
bun test lib/__tests__/file-recency.test.tsExpected: PASS (6 tests).
- Step 5: Commit
cd /Volumes/Code/github.com/seanb4t/octopusgit add apps/web/lib/indexer.ts apps/web/lib/__tests__/file-recency.test.ts # jj-exemptgit commit -m "feat(indexing): collectFileRecency — bare blob-less history scan" # jj-exemptTask 4: Wire the scan into indexRepository + payload + .env.example
Section titled “Task 4: Wire the scan into indexRepository + payload + .env.example”Files:
- Modify:
apps/web/lib/indexer.ts(indexRepository~lines 165–360, payload ~line 614) - Modify:
.env.example(after theEMBEDDING_BATCH_ITEMSblock, ~line 40)
Steps:
- Step 1: Declare the map at function scope
In indexRepository, after let resolvedDefaultBranch: string | undefined; (~line 170), add:
let recencyMap: Map<string, string> | null = null;- Step 2: Invoke the scan in the Bitbucket/GitLab flow
Immediately after onLog("Repository cloned successfully", "success"); (~line 242),
where cloneUrl and gitAuthHeader are in scope:
if (process.env.INDEX_FILE_RECENCY === "true") { recencyMap = await collectFileRecency(cloneUrl, gitAuthHeader, defaultBranch, onLog, signal); }- Step 3: Invoke the scan in the GitHub flow
The GitHub flow is REST-only today — this is its first git invocation. Place the call
after the tree fetch has succeeded (so activeBranch holds the final, possibly
re-resolved branch — see the 404 re-resolve block starting ~line 355) and before file
contents are processed:
if (process.env.INDEX_FILE_RECENCY === "true") { const basicAuth = Buffer.from(`x-access-token:${token}`).toString("base64"); recencyMap = await collectFileRecency( `https://github.com/${fullName}.git`, `Basic ${basicAuth}`, activeBranch, onLog, signal, ); }- Step 4: Stamp the payload
In the points construction (~line 614), add one field after indexedAt:
payload: { repoId, fullName, filePath: chunk.filePath, startLine: chunk.startLine, endLine: chunk.endLine, text: chunk.text, language: chunk.filePath.split(".").pop() ?? "unknown", indexedAt, lastModifiedAt: recencyMap?.get(chunk.filePath) ?? null, },- Step 5: Document the envs in
.env.example
After the EMBEDDING_BATCH_ITEMS=512 line (~line 40), add:
# Set to "true" to record each file's last-commit date (lastModifiedAt) on# indexed chunks via a bare, blob-less clone during indexing. Enables# recency-aware repo analysis and stale-doc demotion in review context.# INDEX_FILE_RECENCY_WINDOW optionally caps the history scan (passed to# git --shallow-since, e.g. "2 years ago"); files last touched before the# window are treated as "age unknown".INDEX_FILE_RECENCY=INDEX_FILE_RECENCY_WINDOW=- Step 6: Typecheck and run the full test suite
cd /Volumes/Code/github.com/seanb4t/octopus/apps/webbun run typecheckbun testExpected: both clean. (With INDEX_FILE_RECENCY unset, behavior is byte-identical:
recencyMap stays null, payloads gain only lastModifiedAt: null.)
Note:
lastModifiedAt: nullon new chunks vs the field being absent on old chunks is intentionally equivalent — every consumer treats null/absent as “age unknown” (spec §1 failure-mode rule).
- Step 7: Commit
cd /Volumes/Code/github.com/seanb4t/octopusgit add apps/web/lib/indexer.ts .env.example # jj-exemptgit commit -m "feat(indexing): INDEX_FILE_RECENCY — lastModifiedAt chunk payload via history scan" # jj-exemptTask 5: incrementalIndex headCommitDate + webhook plumbing; push branch 1
Section titled “Task 5: incrementalIndex headCommitDate + webhook plumbing; push branch 1”Files:
- Modify:
apps/web/lib/indexer.ts(incrementalIndex, ~lines 648–756) - Modify:
apps/web/app/api/github/webhook/route.ts:335–343 - Test:
apps/web/lib/__tests__/incremental-index.test.ts(extend)
Steps:
- Step 1: Write the failing test
Append to the existing describe block in incremental-index.test.ts (it already
mocks upsertChunks as mockUpsertChunks and GitHub file content for src/index.ts):
it("stamps lastModifiedAt from headCommitDate on incremental chunks", async () => { const { incrementalIndex } = await import("@/lib/indexer"); await incrementalIndex( "repo-1", "acme/app", "main", 123, [{ filename: "src/index.ts", status: "modified" }], "github", undefined, "2026-06-01T12:00:00Z", ); const calls = mockUpsertChunks.mock.calls; const points = calls[calls.length - 1][0] as { payload: { lastModifiedAt: string | null } }[]; expect(points[0].payload.lastModifiedAt).toBe("2026-06-01T12:00:00Z"); });
it("stamps lastModifiedAt null when headCommitDate is absent", async () => { const { incrementalIndex } = await import("@/lib/indexer"); await incrementalIndex( "repo-1", "acme/app", "main", 123, [{ filename: "src/index.ts", status: "modified" }], "github", undefined, ); const calls = mockUpsertChunks.mock.calls; const points = calls[calls.length - 1][0] as { payload: { lastModifiedAt: string | null } }[]; expect(points[0].payload.lastModifiedAt).toBeNull(); });(Adapt the incrementalIndex invocation arguments to match how the existing tests in
that file call it — same repo/installation fixtures, only the two new trailing-arg
variants matter.)
- Step 2: Run tests to verify they fail
bun test lib/__tests__/incremental-index.test.tsExpected: FAIL — extra argument rejected / lastModifiedAt undefined.
- Step 3: Add the parameter and payload field
In incrementalIndex (~line 648), add a trailing optional parameter:
export async function incrementalIndex( repoId: string, fullName: string, defaultBranch: string, installationId: number, changedFiles: { filename: string; status: string }[], provider: string = "github", organizationId?: string, /** * ISO date of the push/merge head commit. Stamped as lastModifiedAt on the * re-indexed chunks. null when unknown — never fabricate with now(), which * would fake freshness. */ headCommitDate?: string,): Promise<{ updatedFiles: number; removedFiles: number; newVectors: number }> {and in its points construction (~line 740), add after indexedAt:
indexedAt, lastModifiedAt: headCommitDate ?? null,- Step 4: Plumb the date from the webhook
In apps/web/app/api/github/webhook/route.ts (~line 335; this branch handles merged
PRs — see payload.pull_request?.merged === true at line 296), pass the merge
timestamp:
const result = await incrementalIndex( repo.id, repo.fullName, repo.defaultBranch, installationId, files, "github", repo.organizationId, (payload.pull_request?.merged_at as string | undefined) ?? undefined, );- Step 5: Run tests + typecheck
bun test lib/__tests__/incremental-index.test.ts && bun run typecheckExpected: PASS, clean.
- Step 6: Commit and push branch 1 (CI builds it)
cd /Volumes/Code/github.com/seanb4t/octopusgit add apps/web/lib/indexer.ts apps/web/app/api/github/webhook/route.ts apps/web/lib/__tests__/incremental-index.test.ts # jj-exemptgit commit -m "feat(indexing): stamp lastModifiedAt on incremental re-index from push head commit" # jj-exemptgit push -u origin feat/file-recency-metadata # jj-exemptThe push triggers a multi-arch build; it does not need to finish before Task 6 — only the final tip build (Task 12) is deployed.
Task 6: Branch feat/doc-aware-analysis + pure recency utilities
Section titled “Task 6: Branch feat/doc-aware-analysis + pure recency utilities”Files:
- Create:
apps/web/lib/recency.ts - Test:
apps/web/lib/__tests__/recency.test.ts(create — pure module, no mocks needed)
Steps:
- Step 1: Create the branch (stacked on branch 1)
cd /Volumes/Code/github.com/seanb4t/octopusgit checkout -b feat/doc-aware-analysis feat/file-recency-metadata # jj-exempt- Step 2: Write the failing tests
Create apps/web/lib/__tests__/recency.test.ts:
import { describe, it, expect } from "bun:test";import { chunkKind, medianCodeDate, isStaleDoc, formatAgeAnnotation, selectFromPayloads, STALE_DOC_GAP_MS, type AnalysisChunk,} from "@/lib/recency";
const chunk = (filePath: string, lastModifiedAt: string | null = null, text = "x"): AnalysisChunk => ({ text, filePath, kind: chunkKind(filePath), lastModifiedAt,});
describe("chunkKind", () => { it("classifies md/mdx/txt as doc, everything else as code", () => { expect(chunkKind("docs/design.md")).toBe("doc"); expect(chunkKind("README.mdx")).toBe("doc"); expect(chunkKind("notes.txt")).toBe("doc"); expect(chunkKind("src/app.ts")).toBe("code"); expect(chunkKind("config.yaml")).toBe("code"); });});
describe("medianCodeDate", () => { it("uses distinct dated CODE files only", () => { const median = medianCodeDate([ chunk("a.ts", "2026-01-01T00:00:00Z"), chunk("a.ts", "2026-01-01T00:00:00Z"), // duplicate file — counted once chunk("b.ts", "2026-03-01T00:00:00Z"), chunk("c.ts", "2026-05-01T00:00:00Z"), chunk("old.md", "2020-01-01T00:00:00Z"), // doc — ignored chunk("undated.ts", null), // undated — ignored ]); expect(median).toBe("2026-03-01T00:00:00Z"); });
it("returns null when no code file is dated", () => { expect(medianCodeDate([chunk("a.md", "2026-01-01T00:00:00Z"), chunk("b.ts", null)])).toBeNull(); });});
describe("isStaleDoc", () => { const median = "2026-06-01T00:00:00Z"; it("flags docs more than ~6 months older than the median code date", () => { expect(isStaleDoc("doc", "2025-06-01T00:00:00Z", median)).toBe(true); expect(isStaleDoc("doc", "2026-05-01T00:00:00Z", median)).toBe(false); expect(isStaleDoc("code", "2025-06-01T00:00:00Z", median)).toBe(false); expect(isStaleDoc("doc", null, median)).toBe(false); expect(isStaleDoc("doc", "2025-06-01T00:00:00Z", null)).toBe(false); });});
describe("formatAgeAnnotation", () => { it("formats plain dates and stale-doc relative phrasing", () => { expect(formatAgeAnnotation("2026-05-28T10:00:00Z", null, "code")).toBe(" (last modified 2026-05-28)"); expect(formatAgeAnnotation(null, "2026-06-01T00:00:00Z", "doc")).toBe(""); const stale = formatAgeAnnotation("2024-08-12T00:00:00Z", "2026-06-01T00:00:00Z", "doc"); expect(stale).toContain("last modified 2024-08-12"); expect(stale).toContain("older than typical code in this repo"); });});
describe("selectFromPayloads", () => { it("caps docs at ~20% of the budget", () => { const all = [ ...Array.from({ length: 30 }, (_, i) => chunk(`docs/d${i}.md`, "2026-01-01T00:00:00Z")), ...Array.from({ length: 30 }, (_, i) => chunk(`src/f${i}.ts`, "2026-01-01T00:00:00Z")), ]; const { chunks } = selectFromPayloads(all, 20); expect(chunks.length).toBe(20); expect(chunks.filter((c) => c.kind === "doc").length).toBeLessThanOrEqual(4); });
it("always seeds root README and root manifest", () => { const all = [ chunk("README.md", "2020-01-01T00:00:00Z"), chunk("package.json", "2020-01-01T00:00:00Z"), ...Array.from({ length: 50 }, (_, i) => chunk(`src/f${i}.ts`, "2026-01-01T00:00:00Z")), ]; const { chunks } = selectFromPayloads(all, 10); const paths = chunks.map((c) => c.filePath); expect(paths).toContain("README.md"); expect(paths).toContain("package.json"); });
it("caps any single file at 3 chunks and round-robins top-level dirs", () => { const all = [ ...Array.from({ length: 10 }, () => chunk("src/huge.ts", "2026-01-01T00:00:00Z")), ...Array.from({ length: 10 }, (_, i) => chunk(`lib/l${i}.ts`, "2026-01-01T00:00:00Z")), ]; const { chunks } = selectFromPayloads(all, 12); expect(chunks.filter((c) => c.filePath === "src/huge.ts").length).toBeLessThanOrEqual(3); expect(chunks.some((c) => c.filePath.startsWith("lib/"))).toBe(true); });
it("prefers newer files; undated sort last", () => { const all = [ chunk("src/old.ts", "2020-01-01T00:00:00Z"), chunk("src/new.ts", "2026-01-01T00:00:00Z"), chunk("src/undated.ts", null), ]; const { chunks } = selectFromPayloads(all, 2); const paths = chunks.map((c) => c.filePath); expect(paths[0]).toBe("src/new.ts"); expect(paths).not.toContain("src/undated.ts"); });
it("returns everything for tiny repos (top-up across kinds)", () => { const all = [chunk("README.md"), chunk("docs/a.md"), chunk("docs/b.md"), chunk("src/x.ts")]; const { chunks } = selectFromPayloads(all, 80); expect(chunks.length).toBe(4); });
it("computes the median code date alongside", () => { const { medianCode } = selectFromPayloads( [chunk("a.ts", "2026-01-01T00:00:00Z"), chunk("b.ts", "2026-03-01T00:00:00Z"), chunk("c.ts", "2026-05-01T00:00:00Z")], 80, ); expect(medianCode).toBe("2026-03-01T00:00:00Z"); });});
describe("STALE_DOC_GAP_MS", () => { it("is approximately six months", () => { expect(STALE_DOC_GAP_MS).toBe(183 * 24 * 60 * 60 * 1000); });});- Step 3: Run tests to verify they fail
bun test lib/__tests__/recency.test.tsExpected: FAIL — module @/lib/recency does not exist.
- Step 4: Create
apps/web/lib/recency.ts
// Pure recency/staleness utilities shared by indexing, repo analysis, and// review-context assembly. No I/O — fully unit-testable without mocks.
/** * A doc chunk is "stale" when its last commit is more than ~6 months older * than the median last-commit date of the repo's code files. An absolute gap * is used instead of a relative one (e.g. 2x median age) because relative * thresholds misfire on young repos. */export const STALE_DOC_GAP_MS = 183 * 24 * 60 * 60 * 1000;
const DOC_EXTENSIONS = new Set(["md", "mdx", "txt"]);
const ROOT_MANIFESTS = new Set([ "package.json", "go.mod", "Cargo.toml", "pyproject.toml", "pom.xml", "build.gradle", "Gemfile", "composer.json",]);
export type ChunkKind = "doc" | "code";
export function chunkKind(filePath: string): ChunkKind { const ext = filePath.split(".").pop()?.toLowerCase() ?? ""; return DOC_EXTENSIONS.has(ext) ? "doc" : "code";}
export type AnalysisChunk = { text: string; filePath: string; kind: ChunkKind; lastModifiedAt: string | null;};
/** Median last-modified date across DISTINCT dated code files. */export function medianCodeDate( chunks: { filePath: string; kind: ChunkKind; lastModifiedAt: string | null }[],): string | null { const byFile = new Map<string, string>(); for (const c of chunks) { if (c.kind === "code" && c.lastModifiedAt) byFile.set(c.filePath, c.lastModifiedAt); } const dates = [...byFile.values()].sort(); return dates.length > 0 ? dates[Math.floor(dates.length / 2)] : null;}
export function isStaleDoc( kind: ChunkKind, lastModifiedAt: string | null, median: string | null,): boolean { if (kind !== "doc" || !lastModifiedAt || !median) return false; return Date.parse(median) - Date.parse(lastModifiedAt) > STALE_DOC_GAP_MS;}
/** * " (last modified 2024-08-12 — 14 months older than typical code in this repo)" * Empty string when the date is unknown; relative phrase only for stale docs. */export function formatAgeAnnotation( lastModifiedAt: string | null, median: string | null, kind: ChunkKind,): string { if (!lastModifiedAt) return ""; const date = lastModifiedAt.slice(0, 10); if (isStaleDoc(kind, lastModifiedAt, median)) { const months = Math.round( (Date.parse(median as string) - Date.parse(lastModifiedAt)) / (30 * 24 * 60 * 60 * 1000), ); return ` (last modified ${date} — ${months} months older than typical code in this repo)`; } return ` (last modified ${date})`;}
/** * Structured selection over a repo's full chunk-payload set: * - root README* + root manifest chunks are always seeded; * - docs are capped at ~20% of the budget; * - round-robin across top-level directories, max 3 chunks per file; * - newest lastModifiedAt first within each bucket, undated last; * - tiny repos: a final top-up pass returns everything available. */export function selectFromPayloads( all: AnalysisChunk[], limit: number,): { chunks: AnalysisChunk[]; medianCode: string | null } { const medianCode = medianCodeDate(all);
const newestFirst = (a: AnalysisChunk, b: AnalysisChunk) => { if (a.lastModifiedAt && b.lastModifiedAt) return b.lastModifiedAt.localeCompare(a.lastModifiedAt); if (a.lastModifiedAt) return -1; if (b.lastModifiedAt) return 1; return 0; };
const seeds: AnalysisChunk[] = []; const docs: AnalysisChunk[] = []; const code: AnalysisChunk[] = []; for (const c of all) { const basename = c.filePath.split("/").pop() ?? ""; const isRoot = !c.filePath.includes("/"); if (isRoot && (basename.toLowerCase().startsWith("readme") || ROOT_MANIFESTS.has(basename))) { seeds.push(c); } else if (c.kind === "doc") { docs.push(c); } else { code.push(c); } } docs.sort(newestFirst); code.sort(newestFirst);
const perFile = new Map<string, number>(); const selected: AnalysisChunk[] = [];
// Round-robin across top-level directories, max 3 chunks per file. const take = (pool: AnalysisChunk[], n: number): void => { if (n <= 0) return; const byDir = new Map<string, AnalysisChunk[]>(); for (const c of pool) { const dir = c.filePath.includes("/") ? c.filePath.split("/")[0] : "."; const arr = byDir.get(dir) ?? []; arr.push(c); byDir.set(dir, arr); } let taken = 0; let progressed = true; while (taken < n && progressed) { progressed = false; for (const arr of byDir.values()) { if (taken >= n) break; while (arr.length > 0) { const c = arr.shift() as AnalysisChunk; if ((perFile.get(c.filePath) ?? 0) >= 3) continue; perFile.set(c.filePath, (perFile.get(c.filePath) ?? 0) + 1); selected.push(c); taken++; progressed = true; break; } } } };
take(seeds, seeds.length); // per-file cap bounds this to a handful of chunks take(docs, Math.min(Math.floor(limit * 0.2), limit - selected.length)); take(code, limit - selected.length); take(docs, limit - selected.length); // tiny-repo top-up: spare code budget -> docs
return { chunks: selected, medianCode };}- Step 5: Run tests to verify they pass
bun test lib/__tests__/recency.test.tsExpected: PASS.
- Step 6: Commit
cd /Volumes/Code/github.com/seanb4t/octopusgit add apps/web/lib/recency.ts apps/web/lib/__tests__/recency.test.ts # jj-exemptgit commit -m "feat(analysis): pure recency/selection utilities (chunk kind, median, staleness, selection)" # jj-exemptTask 7: selectAnalysisChunks in qdrant.ts; remove getRepoChunks
Section titled “Task 7: selectAnalysisChunks in qdrant.ts; remove getRepoChunks”Files:
- Modify:
apps/web/lib/qdrant.ts(replacegetRepoChunksat lines 210–227)
Steps:
- Step 1: Replace
getRepoChunkswithselectAnalysisChunks
Delete the getRepoChunks function (qdrant.ts:210–227) and add in its place:
import { selectFromPayloads, chunkKind, type AnalysisChunk } from "@/lib/recency";(import at the top of the file, alongside the existing imports), and:
/** * Structured chunk selection for repo analysis/summarization. Scrolls ALL * payloads for the repo (paginated, vectors excluded — cheap even at 10k+ * chunks) and applies doc-cap / path-diversity / recency selection. */export async function selectAnalysisChunks( repoId: string, limit: number,): Promise<{ chunks: AnalysisChunk[]; medianCode: string | null }> { const qdrant = getQdrantClient(); const all: AnalysisChunk[] = []; let offset: string | number | undefined = undefined; do { const result = await qdrant.scroll(COLLECTION_NAME, { filter: { must: [{ key: "repoId", match: { value: repoId } }], }, limit: 256, offset, with_payload: true, with_vector: false, }); for (const p of result.points) { const text = (p.payload?.text as string) ?? ""; if (!text) continue; const filePath = (p.payload?.filePath as string) ?? ""; all.push({ text, filePath, kind: chunkKind(filePath), lastModifiedAt: (p.payload?.lastModifiedAt as string | null) ?? null, }); } offset = (result.next_page_offset ?? undefined) as string | number | undefined; } while (offset !== undefined);
return selectFromPayloads(all, limit);}- Step 2: Typecheck — expect exactly two callers to break
cd /Volumes/Code/github.com/seanb4t/octopus/apps/webbun run typecheckExpected: errors ONLY in lib/analyzer.ts and lib/summarizer.ts (the two
getRepoChunks importers — fixed in Tasks 8–9). Any other error means an unexpected
caller; stop and investigate.
- Step 3: Commit (red-state commit is fine mid-branch; Tasks 8–9 restore green)
cd /Volumes/Code/github.com/seanb4t/octopusgit add apps/web/lib/qdrant.ts # jj-exemptgit commit -m "feat(analysis): selectAnalysisChunks — structured paginated selection, drop getRepoChunks" # jj-exemptTask 8: Analyzer — doc-aware context + Documentation Accuracy section
Section titled “Task 8: Analyzer — doc-aware context + Documentation Accuracy section”Files:
- Modify:
apps/web/lib/analyzer.ts(lines 1, 40–62, 71, 77–98)
Steps:
- Step 1: Switch imports and selection
Replace line 1:
import { selectAnalysisChunks, getKnowledgeChunksByOrg } from "@/lib/qdrant";and add:
import { formatAgeAnnotation, type AnalysisChunk } from "@/lib/recency";Replace the chunk-fetch block (lines 40–51, from log("Fetching code chunks...")
through let codeContext = chunks.join(...)) with:
log("Fetching code chunks from vector database..."); if (signal?.aborted) throw new Error("Analysis cancelled"); const { chunks, medianCode } = await selectAnalysisChunks(repoId, 80);
if (chunks.length === 0) { log("No indexable content found", "warning"); return "No indexable content found in this repository."; }
const codeChunks = chunks.filter((c) => c.kind === "code"); const docChunks = chunks.filter((c) => c.kind === "doc"); log(`Loaded ${chunks.length} chunks (${codeChunks.length} code, ${docChunks.length} docs)`, "success");
const header = (c: AnalysisChunk) => `// ${c.filePath}${formatAgeAnnotation(c.lastModifiedAt, medianCode, c.kind)}`;
let codeContext = codeChunks.map((c) => `${header(c)}\n${c.text}`).join("\n\n---\n\n"); if (docChunks.length > 0) { codeContext += "\n\n--- Documentation excerpts (prose may be outdated; the code is the source of truth) ---\n\n" + docChunks.map((c) => `${header(c)}\n${c.text}`).join("\n\n---\n\n"); }(The Organization Guidelines append block that follows stays unchanged.)
- Step 2: Update the prompt
In the content template (line 77), change the instruction sentence from
“Provide a thorough analysis with exactly these 6 sections (use ## headers):” to:
Documentation excerpts describe intent at the time they were written. Verify claims against the code; never repeat a documentation claim as fact without code evidence. Provide a thorough analysis with exactly these 7 sections (use ## headers):and append a 7th section definition after the ## Dependencies & Risks block
(before Code snippets:):
## Documentation AccuracyCompare documentation claims against the code. List claims the code contradicts or that appear outdated (cite the doc file and the contradicting code). Note which docs appear current and trustworthy. If no documentation was provided, say so in one line.- Step 3: Raise maxTokens
Line 71: maxTokens: 4096, → maxTokens: 5120, (headroom for the extra section).
- Step 4: Typecheck
bun run typecheckExpected: only lib/summarizer.ts still errors (fixed next task).
- Step 5: Commit
cd /Volumes/Code/github.com/seanb4t/octopusgit add apps/web/lib/analyzer.ts # jj-exemptgit commit -m "feat(analysis): doc-aware context, age annotations, Documentation Accuracy section" # jj-exemptTask 9: Summarizer — same selection, framing line; push branch 2
Section titled “Task 9: Summarizer — same selection, framing line; push branch 2”Files:
- Modify:
apps/web/lib/summarizer.ts(lines 1, 17–26, 37)
Steps:
- Step 1: Switch selection and context build
Replace line 1:
import { selectAnalysisChunks } from "@/lib/qdrant";and add:
import { formatAgeAnnotation } from "@/lib/recency";Replace lines 17–26 (const chunks = await getRepoChunks(repoId, 40); through
const codeContext = chunks.join("\n\n---\n\n");) with:
const { chunks, medianCode } = await selectAnalysisChunks(repoId, 40);
if (chunks.length === 0) { return { summary: "No indexable content found in this repository.", purpose: "Unknown", }; }
const codeContext = chunks .map((c) => `// ${c.filePath}${formatAgeAnnotation(c.lastModifiedAt, medianCode, c.kind)}\n${c.text}`) .join("\n\n---\n\n");- Step 2: Add the framing line to the prompt
In the content template (line 37), after the first line
(You are analyzing the repository "${fullName}". Below are code snippets from the repository.),
add:
Documentation snippets (.md/.txt) describe intent at the time they were written — trust the code over prose where they disagree.- Step 3: Typecheck + full test suite
bun run typecheck && bun testExpected: both clean (all getRepoChunks callers now migrated).
- Step 4: Commit and push branch 2
cd /Volumes/Code/github.com/seanb4t/octopusgit add apps/web/lib/summarizer.ts # jj-exemptgit commit -m "feat(analysis): summarizer uses structured selection + doc-skeptical framing" # jj-exemptgit push -u origin feat/doc-aware-analysis # jj-exemptTask 10: Branch feat/recency-aware-review-context + retrieval/helper
Section titled “Task 10: Branch feat/recency-aware-review-context + retrieval/helper”Files:
- Modify:
apps/web/lib/qdrant.ts(searchSimilarChunksreturn mapping, ~lines 234, 272–278) - Modify:
apps/web/lib/recency.ts(add review-context helper) - Test:
apps/web/lib/__tests__/recency.test.ts(extend)
Steps:
- Step 1: Create the branch (stacked on branch 2)
cd /Volumes/Code/github.com/seanb4t/octopusgit checkout -b feat/recency-aware-review-context feat/doc-aware-analysis # jj-exempt- Step 2: Write the failing tests
Append to apps/web/lib/__tests__/recency.test.ts:
import { applyRecencyToContextChunks, STALE_DOC_KEEP_SCORE, type ReviewContextChunk } from "@/lib/recency";
describe("applyRecencyToContextChunks", () => { const ctx = (filePath: string, lastModifiedAt: string | null, score: number): ReviewContextChunk => ({ filePath, text: "x", startLine: 1, endLine: 10, score, lastModifiedAt, }); // median code date will be 2026-05-01 (single dated code file) const codeChunk = ctx("src/app.ts", "2026-05-01T00:00:00Z", 0.9);
it("demotes stale low-score doc chunks", () => { const staleDoc = ctx("docs/old-design.md", "2024-01-01T00:00:00Z", 0.4); const { chunks, demoted } = applyRecencyToContextChunks([codeChunk, staleDoc]); expect(demoted).toBe(1); expect(chunks.map((c) => c.filePath)).toEqual(["src/app.ts"]); });
it("keeps stale doc chunks with high rerank scores", () => { const staleButRelevant = ctx("docs/old-design.md", "2024-01-01T00:00:00Z", STALE_DOC_KEEP_SCORE + 0.05); const { chunks, demoted } = applyRecencyToContextChunks([codeChunk, staleButRelevant]); expect(demoted).toBe(0); expect(chunks.length).toBe(2); });
it("never demotes undated chunks or code chunks", () => { const undatedDoc = ctx("docs/notes.md", null, 0.3); const oldCode = ctx("src/legacy.ts", "2020-01-01T00:00:00Z", 0.3); const { chunks, demoted } = applyRecencyToContextChunks([codeChunk, undatedDoc, oldCode]); expect(demoted).toBe(0); expect(chunks.length).toBe(3); });
it("builds headers with path:lines, age, and doc tag", () => { const doc = ctx("docs/notes.md", "2026-04-01T00:00:00Z", 0.8); const { chunks } = applyRecencyToContextChunks([codeChunk, doc]); const docHeader = chunks.find((c) => c.filePath === "docs/notes.md")?.contextHeader; expect(docHeader).toBe("// docs/notes.md:L1-L10 (last modified 2026-04-01) (documentation — verify against code)"); const codeHeader = chunks.find((c) => c.filePath === "src/app.ts")?.contextHeader; expect(codeHeader).toBe("// src/app.ts:L1-L10 (last modified 2026-05-01)"); });});- Step 3: Run tests to verify they fail
bun test lib/__tests__/recency.test.tsExpected: FAIL — applyRecencyToContextChunks not exported.
- Step 4: Add the helper to
recency.ts
Append to apps/web/lib/recency.ts:
/** Stale docs survive demotion only above this rerank score. */export const STALE_DOC_KEEP_SCORE = 0.6;
export type ReviewContextChunk = { filePath: string; text: string; startLine: number; endLine: number; score: number; lastModifiedAt: string | null;};
/** * Post-rerank, pre-prompt pass over review context chunks: * - drops doc chunks stale vs the retrieved set's median code date, unless * their rerank score earned them a place (>= STALE_DOC_KEEP_SCORE); * - attaches a contextHeader with path:lines, age, and a doc tag. * Chunks without lastModifiedAt are never demoted ("age unknown"). */export function applyRecencyToContextChunks<T extends ReviewContextChunk>( chunks: T[],): { chunks: (T & { contextHeader: string })[]; demoted: number } { const median = medianCodeDate( chunks.map((c) => ({ filePath: c.filePath, kind: chunkKind(c.filePath), lastModifiedAt: c.lastModifiedAt, })), ); const kept: (T & { contextHeader: string })[] = []; let demoted = 0; for (const c of chunks) { const kind = chunkKind(c.filePath); if (isStaleDoc(kind, c.lastModifiedAt, median) && c.score < STALE_DOC_KEEP_SCORE) { demoted++; continue; } const age = c.lastModifiedAt ? ` (last modified ${c.lastModifiedAt.slice(0, 10)})` : ""; const docTag = kind === "doc" ? " (documentation — verify against code)" : ""; kept.push({ ...c, contextHeader: `// ${c.filePath}:L${c.startLine}-L${c.endLine}${age}${docTag}`, }); } return { chunks: kept, demoted };}- Step 5: Return
lastModifiedAtfromsearchSimilarChunks
In qdrant.ts, update the searchSimilarChunks return type (line ~234):
): Promise<{ filePath: string; text: string; startLine: number; endLine: number; score: number; lastModifiedAt: string | null }[]> {and its result mapping (lines ~272–278), adding one field:
return points.map((point) => ({ filePath: (point.payload?.filePath as string) ?? "", text: (point.payload?.text as string) ?? "", startLine: (point.payload?.startLine as number) ?? 0, endLine: (point.payload?.endLine as number) ?? 0, score: point.score, lastModifiedAt: (point.payload?.lastModifiedAt as string | null) ?? null, }));(Only searchSimilarChunks — do NOT touch searchKnowledgeChunks or the
review-validation.ts callers; they are out of scope per the spec.)
- Step 6: Run tests + typecheck
bun test lib/__tests__/recency.test.ts && bun run typecheckExpected: PASS, clean (the extra returned field is additive — existing callers ignore it).
- Step 7: Commit
cd /Volumes/Code/github.com/seanb4t/octopusgit add apps/web/lib/recency.ts apps/web/lib/qdrant.ts apps/web/lib/__tests__/recency.test.ts # jj-exemptgit commit -m "feat(review): recency-aware context helper + lastModifiedAt in similar-chunk results" # jj-exemptTask 11: Wire both review paths + system prompt; push branch 3
Section titled “Task 11: Wire both review paths + system prompt; push branch 3”Files:
- Modify:
apps/web/lib/reviewer.ts(~lines 1266–1271) - Modify:
apps/web/lib/review-core.ts(~lines 239–241) - Modify:
apps/web/prompts/SYSTEM_PROMPT.md(~lines 93–97)
Steps:
- Step 1: Wire
reviewer.ts(webhook PR flow)
Add the import:
import { applyRecencyToContextChunks } from "@/lib/recency";Replace the codebaseContext construction (lines 1266–1271):
const codebaseContext = contextChunks .map( (c) => `// ${c.filePath}:L${c.startLine}-L${c.endLine}\n${c.text}`, ) .join("\n\n---\n\n");with:
const { chunks: recencyChunks, demoted } = applyRecencyToContextChunks(contextChunks); if (demoted > 0) { console.log(`[reviewer] Demoted ${demoted} stale documentation chunk(s) from review context`); } const codebaseContext = recencyChunks .map((c) => `${c.contextHeader}\n${c.text}`) .join("\n\n---\n\n");- Step 2: Wire
review-core.ts(local-review/API flow) identically
Add the same import, and replace its codebaseContext construction (lines 239–241):
const codebaseContext = contextChunks .map((c) => `// ${c.filePath}:L${c.startLine}-L${c.endLine}\n${c.text}`) .join("\n\n---\n\n");with:
const { chunks: recencyChunks, demoted } = applyRecencyToContextChunks(contextChunks); if (demoted > 0) { console.log(`[review-core] Demoted ${demoted} stale documentation chunk(s) from review context`); } const codebaseContext = recencyChunks .map((c) => `${c.contextHeader}\n${c.text}`) .join("\n\n---\n\n");- Step 3: Add the code-over-docs line to the system prompt
In apps/web/prompts/SYSTEM_PROMPT.md, the <codebase_context> processing list
(lines 93–97) currently reads:
When processing this context:- Cross-reference multiple chunks to build a complete picture- Note when chunks seem outdated or contradictory- Consider the file path hierarchy to understand module boundaries- Use import/export statements to trace dependency chainsAdd one bullet after “Note when chunks seem outdated or contradictory”:
- Treat prose documentation chunks (.md/.txt) as statements of intent at the time they were written; where documentation and code conflict, the code is authoritative- Step 4: Full test suite + typecheck
cd /Volumes/Code/github.com/seanb4t/octopus/apps/webbun test && bun run typecheckExpected: both clean.
- Step 5: Commit and push branch 3 (the new stack tip)
cd /Volumes/Code/github.com/seanb4t/octopusgit add apps/web/lib/reviewer.ts apps/web/lib/review-core.ts apps/web/prompts/SYSTEM_PROMPT.md # jj-exemptgit commit -m "feat(review): annotate ages + demote stale docs in both review context paths" # jj-exemptgit push -u origin feat/recency-aware-review-context # jj-exemptTask 12: Watch the tip build; capture digests
Section titled “Task 12: Watch the tip build; capture digests”Files: none (CI observation)
Steps:
- Step 1: Watch the tip branch’s build
gh run list --repo seanb4t/octopus --branch feat/recency-aware-review-context --limit 1 --json databaseId,status --jq '.[0]'gh run watch <databaseId> --repo seanb4t/octopus --exit-statusExpected: multi-arch build (amd64 + arm64) succeeds for both images.
- Step 2: Capture both digests for the tip commit
cd /Volumes/Code/github.com/seanb4t/octopusSHA=$(git rev-parse --short HEAD) # jj-exempt (read-only plumbing)docker buildx imagetools inspect ghcr.io/seanb4t/octopus:$SHA | head -5docker buildx imagetools inspect ghcr.io/seanb4t/octopus-migrate:$SHA | head -5Record the two Digest: sha256:… values — Task 13 pins them.
Task 13: Cluster rollout PR (digests + env + ops doc)
Section titled “Task 13: Cluster rollout PR (digests + env + ops doc)”Files (cluster repo, in a dedicated jj workspace):
- Modify:
argocd/app-configs/octopus/deployment.yaml(image digests at lines ~38 and ~50; env list ~line 80) - Modify:
docs/operations/octopus.md(fork-state table; ops note)
Steps:
- Step 1: Create a jj workspace off latest main
cd /Volumes/Code/github.com/fzymgc-house/selfhosted-clusterjj --no-pager git fetchjj --no-pager workspace add .worktrees/octopus-recency-rollout --name octopus-recency-rollout -r main@origincd .worktrees/octopus-recency-rollout- Step 2: Repoint BOTH image digests
In argocd/app-configs/octopus/deployment.yaml, replace the digest on the migrate
initContainer image (line ~38) and the web container image (line ~50) with the two
digests captured in Task 12:
image: ghcr.io/seanb4t/octopus-migrate@sha256:<MIGRATE_DIGEST_FROM_TASK_12> image: ghcr.io/seanb4t/octopus@sha256:<WEB_DIGEST_FROM_TASK_12>- Step 3: Add the recency env
In the same file’s env list, after the DISABLE_REVIEW_BRANDING entry (~line 80):
# Record per-file last-commit dates during indexing (recency-aware # analysis + stale-doc demotion in review context). Re-index a repo # to backfill; pre-existing chunks read as "age unknown". - name: INDEX_FILE_RECENCY value: "true"- Step 4: Update the ops doc
In docs/operations/octopus.md:
a. Append three rows to the Fork state stacked-branch table (after the
feat/optional-review-branding row, which loses its “(tip)” marker to the last
new row):
| `feat/file-recency-metadata` | `INDEX_FILE_RECENCY` + `INDEX_FILE_RECENCY_WINDOW` — per-file `lastModifiedAt` chunk payload via bare blob-less history scan | `INDEX_FILE_RECENCY=true` || `feat/doc-aware-analysis` | Structured analysis-chunk selection (doc cap, path diversity, recency) + `## Documentation Accuracy` analysis section | — || `feat/recency-aware-review-context` (**tip**) | Age annotations + stale-doc demotion in review context (webhook + local review paths) | — |b. Add an operations note in the Operations section:
### Backfill file-recency metadata
`lastModifiedAt` is stamped at index time. Repos indexed before the recency rolloutread as "age unknown" (no annotations, no stale-doc demotion) until re-indexed:Cancel + re-index from the repo page. Analysis/selection improvements applyregardless; only age-awareness needs the re-index.- Step 5: Lint, commit, push, PR
yamllint argocd/app-configs/octopus/deployment.yamlrumdl check docs/operations/octopus.mdjj --no-pager describe -m "feat(octopus): recency-aware analysis rollout — repoint digests, INDEX_FILE_RECENCY, ops doc [hl-9d8]"jj --no-pager bookmark create feat/octopus-recency-rollout -r @jj --no-pager git push -b feat/octopus-recency-rollout --allow-newgh pr create --head feat/octopus-recency-rollout --title "feat(octopus): recency-aware, doc-skeptical analysis rollout [hl-9d8]" --body "Repoints octopus web+migrate digests to the feat/recency-aware-review-context stack tip, enables INDEX_FILE_RECENCY, updates the fork-state table + backfill ops note. Spec: docs/engineering/specs/2026-06-07-octopus-doc-aware-analysis-design.md
🤖 Generated with [Claude Code](https://claude.com/claude-code)"Expected: PR opens; the user merges (branch protection). ArgoCD syncs on merge.
Task 14: E2E validation (after PR merge + ArgoCD sync)
Section titled “Task 14: E2E validation (after PR merge + ArgoCD sync)”Files: none (verification)
Steps:
- Step 1: Verify the pod runs the new digest with the new env
kubectl --context fzymgc-house -n octopus get pod -l app=octopus \ -o jsonpath='{.items[0].status.containerStatuses[0].imageID}{"\n"}'kubectl --context fzymgc-house -n octopus get deploy octopus \ -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="INDEX_FILE_RECENCY")].value}{"\n"}'Expected: imageID ends with the Task-12 web digest; env prints true.
- Step 2: Re-index weft (backfills recency)
In the octopus UI (octopus.fzymgc.house): repo page → Cancel (if stuck) → Re-index.
Watch logs:
kubectl --context fzymgc-house -n octopus logs deploy/octopus -f | grep -iE "recency|index|embed"Expected: File-recency scan dated N paths; indexing completes inside the known
envelope (~16 batches @64, ~160 s, zero 503s). The embedding pipeline limits table in
docs/operations/octopus.md is untouched by this work — any 503/504 here is a
regression in the environment, not this feature; stop and check gateway metrics at
the agentgateway pod :15020/metrics.
- Step 3: Spot-check a Qdrant payload
kubectl --context fzymgc-house -n octopus exec deploy/octopus -- sh -c \ 'wget -qO- "http://qdrant-octopus.octopus.svc.cluster.local:6333/collections/code_chunks/points/scroll" \ --header "Content-Type: application/json" \ --post-data "{\"limit\":2,\"with_payload\":[\"filePath\",\"lastModifiedAt\"]}"'Expected: points carry a non-null ISO lastModifiedAt.
- Step 4: Re-run analysis and inspect the output
Repo page → Analyze. Expected in the stored analysis: a ## Documentation Accuracy
section; age annotations visible in any doc/code mismatch citations.
- Step 5: Exercise a PR review
Open a throwaway PR on weft (or re-trigger review on an open one). Expected: review
posts normally; pod logs may show Demoted N stale documentation chunk(s) when stale
docs were in retrieval; review completes through agentgateway → deepseek-v4-pro.
- Step 6: Close out
bd close hl-9d8.<task-beads-as-completed> # per-bead closes during executionbd note hl-9d8 "E2E validated: recency backfill on weft, Documentation Accuracy section present, review path clean"Follow-up (not this plan): extend hl-0we.4’s upstream batch with these three branches as upstream PRs once validated in production.