Skip to content

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


RepoPathVCSRules
Fork (Tasks 1–12)/Volumes/Code/github.com/seanb4t/octopusplain git — NOT jjAppend # 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-clusterjj colocatedCreate 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/:

Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus/apps/web
bun test # all unit tests
bun run typecheck # tsc --noEmit

Task 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
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus
git fetch origin # jj-exempt
git 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
Terminal window
git add .github/workflows/build-image.yaml # jj-exempt
git commit -m "ci: build feat/file-recency-metadata + doc-aware stack branches" # jj-exempt

Task 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 near shouldIndex around 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
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus/apps/web
bun test lib/__tests__/file-recency.test.ts

Expected: 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
Terminal window
bun test lib/__tests__/file-recency.test.ts

Expected: PASS (4 tests).

  • Step 5: Commit
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus
git add apps/web/lib/indexer.ts apps/web/lib/__tests__/file-recency.test.ts # jj-exempt
git commit -m "feat(indexing): git-log name-status parser for file recency" # jj-exempt

Task 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 below parseGitLogNameStatus)
  • 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
Terminal window
bun test lib/__tests__/file-recency.test.ts

Expected: FAIL — collectFileRecency is not exported.

  • Step 3: Implement collectFileRecency in indexer.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
Terminal window
bun test lib/__tests__/file-recency.test.ts

Expected: PASS (6 tests).

  • Step 5: Commit
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus
git add apps/web/lib/indexer.ts apps/web/lib/__tests__/file-recency.test.ts # jj-exempt
git commit -m "feat(indexing): collectFileRecency — bare blob-less history scan" # jj-exempt

Task 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 the EMBEDDING_BATCH_ITEMS block, ~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:

Terminal window
# 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
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus/apps/web
bun run typecheck
bun test

Expected: both clean. (With INDEX_FILE_RECENCY unset, behavior is byte-identical: recencyMap stays null, payloads gain only lastModifiedAt: null.)

Note: lastModifiedAt: null on 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
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus
git add apps/web/lib/indexer.ts .env.example # jj-exempt
git commit -m "feat(indexing): INDEX_FILE_RECENCY — lastModifiedAt chunk payload via history scan" # jj-exempt

Task 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
Terminal window
bun test lib/__tests__/incremental-index.test.ts

Expected: 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
Terminal window
bun test lib/__tests__/incremental-index.test.ts && bun run typecheck

Expected: PASS, clean.

  • Step 6: Commit and push branch 1 (CI builds it)
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus
git add apps/web/lib/indexer.ts apps/web/app/api/github/webhook/route.ts apps/web/lib/__tests__/incremental-index.test.ts # jj-exempt
git commit -m "feat(indexing): stamp lastModifiedAt on incremental re-index from push head commit" # jj-exempt
git push -u origin feat/file-recency-metadata # jj-exempt

The 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)
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus
git 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
Terminal window
bun test lib/__tests__/recency.test.ts

Expected: 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
Terminal window
bun test lib/__tests__/recency.test.ts

Expected: PASS.

  • Step 6: Commit
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus
git add apps/web/lib/recency.ts apps/web/lib/__tests__/recency.test.ts # jj-exempt
git commit -m "feat(analysis): pure recency/selection utilities (chunk kind, median, staleness, selection)" # jj-exempt

Task 7: selectAnalysisChunks in qdrant.ts; remove getRepoChunks

Section titled “Task 7: selectAnalysisChunks in qdrant.ts; remove getRepoChunks”

Files:

  • Modify: apps/web/lib/qdrant.ts (replace getRepoChunks at lines 210–227)

Steps:

  • Step 1: Replace getRepoChunks with selectAnalysisChunks

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
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus/apps/web
bun run typecheck

Expected: 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)
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus
git add apps/web/lib/qdrant.ts # jj-exempt
git commit -m "feat(analysis): selectAnalysisChunks — structured paginated selection, drop getRepoChunks" # jj-exempt

Task 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 Accuracy
Compare 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
Terminal window
bun run typecheck

Expected: only lib/summarizer.ts still errors (fixed next task).

  • Step 5: Commit
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus
git add apps/web/lib/analyzer.ts # jj-exempt
git commit -m "feat(analysis): doc-aware context, age annotations, Documentation Accuracy section" # jj-exempt

Task 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
Terminal window
bun run typecheck && bun test

Expected: both clean (all getRepoChunks callers now migrated).

  • Step 4: Commit and push branch 2
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus
git add apps/web/lib/summarizer.ts # jj-exempt
git commit -m "feat(analysis): summarizer uses structured selection + doc-skeptical framing" # jj-exempt
git push -u origin feat/doc-aware-analysis # jj-exempt

Task 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 (searchSimilarChunks return 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)
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus
git 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
Terminal window
bun test lib/__tests__/recency.test.ts

Expected: 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 lastModifiedAt from searchSimilarChunks

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
Terminal window
bun test lib/__tests__/recency.test.ts && bun run typecheck

Expected: PASS, clean (the extra returned field is additive — existing callers ignore it).

  • Step 7: Commit
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus
git add apps/web/lib/recency.ts apps/web/lib/qdrant.ts apps/web/lib/__tests__/recency.test.ts # jj-exempt
git commit -m "feat(review): recency-aware context helper + lastModifiedAt in similar-chunk results" # jj-exempt

Task 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 chains

Add 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
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus/apps/web
bun test && bun run typecheck

Expected: both clean.

  • Step 5: Commit and push branch 3 (the new stack tip)
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus
git add apps/web/lib/reviewer.ts apps/web/lib/review-core.ts apps/web/prompts/SYSTEM_PROMPT.md # jj-exempt
git commit -m "feat(review): annotate ages + demote stale docs in both review context paths" # jj-exempt
git push -u origin feat/recency-aware-review-context # jj-exempt

Task 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
Terminal window
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-status

Expected: multi-arch build (amd64 + arm64) succeeds for both images.

  • Step 2: Capture both digests for the tip commit
Terminal window
cd /Volumes/Code/github.com/seanb4t/octopus
SHA=$(git rev-parse --short HEAD) # jj-exempt (read-only plumbing)
docker buildx imagetools inspect ghcr.io/seanb4t/octopus:$SHA | head -5
docker buildx imagetools inspect ghcr.io/seanb4t/octopus-migrate:$SHA | head -5

Record 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
Terminal window
cd /Volumes/Code/github.com/fzymgc-house/selfhosted-cluster
jj --no-pager git fetch
jj --no-pager workspace add .worktrees/octopus-recency-rollout --name octopus-recency-rollout -r main@origin
cd .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 rollout
read as "age unknown" (no annotations, no stale-doc demotion) until re-indexed:
Cancel + re-index from the repo page. Analysis/selection improvements apply
regardless; only age-awareness needs the re-index.
  • Step 5: Lint, commit, push, PR
Terminal window
yamllint argocd/app-configs/octopus/deployment.yaml
rumdl check docs/operations/octopus.md
jj --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-new
gh 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
Terminal window
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:

Terminal window
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
Terminal window
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
Terminal window
bd close hl-9d8.<task-beads-as-completed> # per-bead closes during execution
bd 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.