@dailephd/my-dev-kit-lab 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to my-dev-kit-lab are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.0] - 2026-09-24
8
+
9
+ Index freshness and changed-file detection.
10
+
11
+ - Added `IndexSnapshotV1` (schema `my-dev-kit-lab-index-snapshot-v1`): after a warm-index run's one index build, Lab interprets the my-dev-kit manifest and symbol index and records baseline index evidence — manifest metadata, the exact indexed-file set the upstream index contract lists (never a directory walk), per-file SHA-256, size, and modified-time metadata, the index command, and an inventory of generated index artifacts. Snapshots are `complete`, `partial`, or `unavailable` with explicit reasons; source contents are never persisted.
12
+ - Added a configured my-dev-kit `--version` probe, run once per prepared index session outside the measured index build. An unsupported or failing probe is non-fatal and recorded as explicitly unavailable.
13
+ - Added `IndexFreshnessAssessmentV1` (schema `my-dev-kit-lab-index-freshness-v1`): a read-only comparison of the files the snapshot lists against their current state. SHA-256 is the comparison identity; modified time is diagnostic only. Modified and missing indexed files are confirmed changes; unreadable or unresolvable files stay explicit unresolved evidence; files absent from the snapshot are not inferred to be newly indexable. Exactly four statuses: `fresh`, `stale`, `partially-stale`, `unknown`.
14
+ - Freshness is assessed for every task immediately before its warm retrieval and is observational only: it never triggers or recommends reindexing, suppresses retrieval, or changes execution, provider, agent, correctness, or token-evidence status. One index is still built per benchmark project.
15
+ - Extended `warm-index-execution.json` additively (schema `my-dev-kit-lab-warm-index-execution-v1` unchanged) with project-level `indexSnapshot` and task-level `indexFreshness`.
16
+ - Extended the warm-index report additively (schema `my-dev-kit-lab-warm-index-report-v1` unchanged) with an index freshness summary (assessed, unassessed, fresh, stale, partially-stale, and unknown task counts) and per-task freshness evidence in `report.json`, `report.txt`, and `report.html`. Report evidence is bounded to 20 changed files and 10 unresolved entries per task, omits content hashes, and escapes dynamic strings; a task with no assessment is shown as not assessed, distinct from `unknown`. Report limitations and the run interpretation state what freshness does and does not establish.
17
+ - No new CLI command or flag, `ExperimentMetric`, plot, gallery item, or experiment plugin was added: the warm-index metrics, exactly four plots, and the three-item campaign gallery are unchanged, and no reindex recommendation exists.
18
+ - Extended the packed-package acceptance gate (`npm run verify:packed-package`) to prove the exact tarball ships the snapshot and freshness runtime and that an installed warm-index run produces snapshot, tool-version, freshness, and report freshness evidence while the target, installed package, and packaged examples stay unchanged.
19
+ - Validation of the implementation candidate: exact-SHA ordinary CI passed on Ubuntu, macOS, and Windows with Node 24 and latest, and the full local suite passed with the packed-package gate.
20
+
7
21
  ## [0.5.2] - 2026-09-23
8
22
 
9
23
  Warm-index real-agent campaigns.
package/README.md CHANGED
@@ -6,9 +6,7 @@ my-dev-kit provides local repository indexing and graph-guided retrieval. my-dev
6
6
 
7
7
  Whole-ecosystem workflows are centralized in [my-dev-kit/docs/ECOSYSTEM_DEVELOPMENT_WORKFLOWS.md](https://github.com/dailephd/my-dev-kit/blob/main/docs/ECOSYSTEM_DEVELOPMENT_WORKFLOWS.md). Its [command-surface compatibility map](https://github.com/dailephd/my-dev-kit/blob/main/docs/ECOSYSTEM_DEVELOPMENT_WORKFLOWS.md#915-command-surface-compatibility-map) documents which Lab outputs can safely seed my-dev-kit investigation, Observer reference work, or Orchestrator evidence, and which look-alike artifacts are not direct inputs.
8
8
 
9
- The latest release is v0.5.2 (warm-index real-agent campaigns). The previous release, v0.5.1, added the expanded warm-index benchmark suite. The v0.4.5 context-integrity evaluation remains intentionally frozen against the published `@dailephd/my-dev-kit@1.10.4` and `@dailephd/my-dev-kit-orchestrator@1.2.3` contracts; those versions are historical validation baselines, not a statement that they are the ecosystem's current releases. See [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md).
10
-
11
- The current release is v0.5.2, and the package and installed CLI are version 0.5.2. v0.5.1 (expanded warm-index benchmark suite) is the previous release; v0.6.0 (index freshness and changed-file detection) is next planned. See [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md) for current state.
9
+ The latest release is v0.6.0 (index freshness and changed-file detection), and the package and installed CLI are version 0.6.0. The previous release is v0.5.2 (warm-index real-agent campaigns), and v0.6.1 (affected-neighborhood experiments) is next planned. The v0.4.5 context-integrity evaluation remains intentionally frozen against the published `@dailephd/my-dev-kit@1.10.4` and `@dailephd/my-dev-kit-orchestrator@1.2.3` contracts; those versions are historical validation baselines, not a statement that they are the ecosystem's current releases. See [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md) for current state.
12
10
 
13
11
  v0.4.6 adds a supported `my-dev-kit-lab` installed CLI router (`--help`, `--version`, `security validate`, `audit`, the `experiment` family, `report render`, `plots generate`, `gallery build`, `demo final`, and the historical direct final-demo invocation form), a writable lab workspace model kept separate from the installed package and the inspected target, and a permanent packed-tarball installation/execution acceptance gate (`npm run verify:packed-package`). See [docs/ROADMAP.md](docs/ROADMAP.md) and [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md) for status detail and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the runtime path model. The "Installed CLI" section below documents the shipped command surface; the source-checkout `npm run` workflow in Quickstart remains available for contributors.
14
12
 
@@ -17,6 +15,7 @@ v0.4.6 adds a supported `my-dev-kit-lab` installed CLI router (`--help`, `--vers
17
15
  - **Run context-strategy experiments:** compare `raw-full-file` with `my-dev-kit-guided` using deterministic fixtures or locally configured Codex and Claude CLIs.
18
16
  - **Measure warm-index reuse (v0.5.0–v0.5.1, released):** the `warm-index-reuse` experiment plugin builds one my-dev-kit index per benchmark project, reuses it across that project's tasks, and compares every task with a matched `raw-full-file` baseline. Reports separate one-time index-build cost from per-task retrieval cost, show amortized index cost and cumulative measurements, and include deterministic fake-agent correctness and token evidence; `plots generate` renders four warm-index SVG charts. The v0.5.1 release adds a dedicated 12-task benchmark corpus (`benchmarks/contracts/warm-index-benchmark-cases.json`: six medium and six large/mixed tasks tagged `localized`, `cross-module`, or `broad-change`) for comparing warm-index behavior as task count grows. Estimated context tokens (a character-based context-size estimate) and fake-agent total tokens (simulated harness telemetry) are reported separately, and neither is provider billing telemetry.
19
17
  - **Run selectable real-agent warm-index campaigns (v0.5.2, released):** `--campaign codex-full`, `claude-full`, or `codex-timeout-isolation` (with `--include-real-agents`) runs exactly one provider — Codex or Claude — against the exact raw and warm contexts the experiment already measured, over the frozen production corpus each preset owns. Provider outcomes (`completed`, `failed`, `timeout`, `invalid-output`, `agent-unavailable`, `agent-limit-reached`, `skipped`) are classified and reported separately from warm-index infrastructure status, so a campaign can complete its indexing/retrieval work even when provider evidence is partial or unavailable. A campaign run automatically produces the report, the same four warm-index SVG charts, a best-effort report screenshot (skipped without a browser runtime, never fatal), and a three-item gallery (report, plots, bounded execution evidence). Legacy runs without `--campaign` keep the existing deterministic fake-agent, report-only behavior unchanged. See [docs/WORKFLOWS.md](docs/WORKFLOWS.md#warm-index-reuse-experiment) and [docs/METRICS.md](docs/METRICS.md#warm-index-reuse-metrics).
18
+ - **Record index freshness evidence (v0.6.0, current):** a warm-index run captures an index snapshot after its one index build — the exact indexed file set from the my-dev-kit index contract, each with SHA-256 content identity, size, and modified-time metadata, plus the my-dev-kit version and index-command evidence — and assesses freshness immediately before each task's warm retrieval as `fresh`, `stale`, `partially-stale`, or `unknown`. The evidence appears in `warm-index-execution.json` and in the existing `report.json`/`report.txt`/`report.html` warm-index reports. It is observational only: it covers only files the snapshot lists (not the whole repository), never changes retrieval, execution or provider status, and adds no CLI flag, metric, plot, gallery item, or reindex recommendation. See [docs/METRICS.md](docs/METRICS.md#index-freshness-evidence-v060) and [docs/WORKFLOWS.md](docs/WORKFLOWS.md#warm-index-reuse-experiment).
20
19
  - **Audit repository health:** run conservative code-rot detectors for TypeScript/JavaScript, Python, Java, and Kotlin, or adapt security findings into the common audit report.
21
20
  - **Validate CLI/package security:** inspect dependencies, package contents, path and subprocess boundaries, malformed inputs, optional static scanners, and bounded fuzz targets.
22
21
  - **Validate Android projects:** run nineteen static checks by default, with Gradle operations, external tools, and network access available only through explicit opt-in flags.
@@ -200,7 +199,7 @@ When `--target` is omitted, the experiment runs in self mode against my-dev-kit-
200
199
  | Plugin experiment report JSON | `lab-output/experiments/<plugin>/<target>/<run>/report.json` |
201
200
  | Plugin experiment report HTML | `lab-output/experiments/<plugin>/<target>/<run>/report.html` |
202
201
  | Plugin experiment report text | `lab-output/experiments/<plugin>/<target>/<run>/report.txt` |
203
- | Warm-index execution evidence | `<experiment out>/warm-index-execution.json` (plus `indexes/`, `commands/`, and `agents/` beneath the same output root) |
202
+ | Warm-index execution evidence | `<experiment out>/warm-index-execution.json` (plus `indexes/`, `commands/`, and `agents/` beneath the same output root); the v0.6.0 artifact carries project-level `indexSnapshot` and per-task `indexFreshness` evidence |
204
203
  | Warm-index campaign presentation (v0.5.2) | `<experiment out>/plots/` (four charts), `<experiment out>/report.png` (best-effort), `<experiment out>/gallery/gallery-manifest.json` and `gallery-index.html` — automatic for `--campaign` runs only |
205
204
  | Plot data | `lab-output/<plots>/plot-data.json` |
206
205
  | SVG charts | `lab-output/<plots>/charts/*.svg` |
@@ -0,0 +1,199 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { compareCodeUnits, mapInBatches, resolveIndexedFilePath, snapshotIndexedFile, } from "./indexSnapshot.js";
3
+ export const INDEX_FRESHNESS_SCHEMA_VERSION = "my-dev-kit-lab-index-freshness-v1";
4
+ const MAX_CHANGES = 100;
5
+ const MAX_UNRESOLVED = 50;
6
+ const MAX_REPORTED_PATH_LENGTH = 260;
7
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/;
8
+ /**
9
+ * Policy: the four-state freshness decision table. `fresh` requires a complete baseline and a
10
+ * complete comparison with no confirmed change; a confirmed change is `stale` only when the
11
+ * comparison was complete, otherwise `partially-stale`; without a confirmed change an incomplete
12
+ * comparison is `unknown`. Never converts uncertainty to `fresh`. Pure.
13
+ */
14
+ export function classifyIndexFreshness(input) {
15
+ if (input.baselineSnapshotStatus === "unavailable") {
16
+ return "unknown";
17
+ }
18
+ const comparisonComplete = input.baselineSnapshotStatus === "complete" && input.unresolvedCount === 0;
19
+ if (input.confirmedChangeCount > 0) {
20
+ return comparisonComplete ? "stale" : "partially-stale";
21
+ }
22
+ return comparisonComplete ? "fresh" : "unknown";
23
+ }
24
+ function boundedText(value) {
25
+ return value.length > MAX_REPORTED_PATH_LENGTH ? `${value.slice(0, MAX_REPORTED_PATH_LENGTH)}...` : value;
26
+ }
27
+ function isValidBaselineEntry(entry) {
28
+ if (typeof entry !== "object" || entry === null)
29
+ return false;
30
+ const candidate = entry;
31
+ return (typeof candidate.path === "string" &&
32
+ candidate.path.length > 0 &&
33
+ typeof candidate.sha256 === "string" &&
34
+ SHA256_PATTERN.test(candidate.sha256) &&
35
+ typeof candidate.sizeBytes === "number" &&
36
+ typeof candidate.modifiedAt === "string");
37
+ }
38
+ async function compareIndexedFile(targetRoot, realTargetRoot, sourceRoots, entry) {
39
+ if (!isValidBaselineEntry(entry)) {
40
+ const listedPath = typeof entry?.path === "string" ? boundedText(entry.path) : null;
41
+ return {
42
+ kind: "unresolved",
43
+ unresolved: {
44
+ path: listedPath,
45
+ reasonCode: "unsupported-snapshot-entry",
46
+ message: "Snapshot entry lacks a path, SHA-256, size, or modified time.",
47
+ },
48
+ };
49
+ }
50
+ const resolution = resolveIndexedFilePath(targetRoot, sourceRoots, entry.path);
51
+ if (!resolution.ok) {
52
+ return {
53
+ kind: "unresolved",
54
+ unresolved: { path: boundedText(entry.path), reasonCode: "unsafe-path", message: `Path cannot be safely resolved (${resolution.reason}).` },
55
+ };
56
+ }
57
+ const current = await snapshotIndexedFile(targetRoot, realTargetRoot, resolution.relativePath);
58
+ if (!current.ok) {
59
+ if (current.reason === "missing") {
60
+ return {
61
+ kind: "change",
62
+ change: {
63
+ path: entry.path,
64
+ changeType: "missing",
65
+ baselineSha256: entry.sha256,
66
+ baselineSizeBytes: entry.sizeBytes,
67
+ baselineModifiedAt: entry.modifiedAt,
68
+ currentSha256: null,
69
+ currentSizeBytes: null,
70
+ currentModifiedAt: null,
71
+ },
72
+ };
73
+ }
74
+ const unsafe = current.reason === "outside-target-or-source-roots" || current.reason === "invalid-path";
75
+ return {
76
+ kind: "unresolved",
77
+ unresolved: {
78
+ path: boundedText(entry.path),
79
+ reasonCode: unsafe ? "unsafe-path" : "file-read-failed",
80
+ message: unsafe ? "Current file resolves outside the target." : `Current file could not be read as a regular file (${current.reason}).`,
81
+ },
82
+ };
83
+ }
84
+ if (current.file.sha256 === entry.sha256) {
85
+ return { kind: "unchanged" };
86
+ }
87
+ return {
88
+ kind: "change",
89
+ change: {
90
+ path: entry.path,
91
+ changeType: "modified",
92
+ baselineSha256: entry.sha256,
93
+ baselineSizeBytes: entry.sizeBytes,
94
+ baselineModifiedAt: entry.modifiedAt,
95
+ currentSha256: current.file.sha256,
96
+ currentSizeBytes: current.file.sizeBytes,
97
+ currentModifiedAt: current.file.modifiedAt,
98
+ },
99
+ };
100
+ }
101
+ /**
102
+ * Compares the files an index snapshot lists against their current state. Read-only: never
103
+ * modifies the target, invokes my-dev-kit, re-indexes, or retrieves. Files that exist now but are
104
+ * absent from the snapshot are deliberately ignored — the snapshot records the exact indexed set,
105
+ * not everything under the source roots, so "added" files cannot be inferred truthfully. Never
106
+ * throws; an unassessable input yields an explicit `unknown` assessment.
107
+ */
108
+ export async function assessIndexFreshness(options) {
109
+ const { snapshot, targetRoot, sourceRoots } = options;
110
+ const assessedAt = (options.now ?? (() => new Date()))().toISOString();
111
+ const baselineSnapshotStatus = snapshot?.status ?? "unavailable";
112
+ const changes = [];
113
+ const unresolved = [];
114
+ let unchangedFileCount = 0;
115
+ let unresolvedTotal = 0;
116
+ const addUnresolved = (entry, count = 1) => {
117
+ unresolvedTotal += count;
118
+ unresolved.push(entry);
119
+ };
120
+ try {
121
+ if (!snapshot || snapshot.status === "unavailable") {
122
+ addUnresolved({
123
+ path: null,
124
+ reasonCode: "snapshot-unavailable",
125
+ message: snapshot?.unavailable ? boundedText(snapshot.unavailable.message) : "No index snapshot is available for this session.",
126
+ });
127
+ }
128
+ else {
129
+ let realTargetRoot;
130
+ try {
131
+ realTargetRoot = await realpath(targetRoot);
132
+ }
133
+ catch {
134
+ addUnresolved({ path: null, reasonCode: "file-read-failed", message: "Target root could not be resolved." });
135
+ }
136
+ if (snapshot.status === "complete" && snapshot.files.length !== snapshot.indexedFileCount) {
137
+ addUnresolved({
138
+ path: null,
139
+ reasonCode: "unsupported-snapshot-entry",
140
+ message: `Complete snapshot lists ${snapshot.indexedFileCount} indexed files but carries ${snapshot.files.length} entries.`,
141
+ });
142
+ }
143
+ for (const entry of snapshot.unresolvedFiles) {
144
+ addUnresolved({
145
+ path: entry.path,
146
+ reasonCode: "snapshot-file-unresolved",
147
+ message: `The index snapshot could not hash this indexed file (${entry.reason}).`,
148
+ });
149
+ }
150
+ // Unresolved entries the snapshot itself truncated still count toward the total.
151
+ unresolvedTotal += Math.max(0, snapshot.unresolvedFileCount - snapshot.unresolvedFiles.length);
152
+ if (realTargetRoot !== undefined) {
153
+ const rootForComparison = realTargetRoot;
154
+ const comparisons = await mapInBatches(snapshot.files, (entry) => compareIndexedFile(targetRoot, rootForComparison, sourceRoots, entry));
155
+ for (const comparison of comparisons) {
156
+ if (comparison.kind === "unchanged")
157
+ unchangedFileCount += 1;
158
+ else if (comparison.kind === "change")
159
+ changes.push(comparison.change);
160
+ else
161
+ addUnresolved(comparison.unresolved);
162
+ }
163
+ }
164
+ }
165
+ }
166
+ catch (error) {
167
+ addUnresolved({
168
+ path: null,
169
+ reasonCode: "file-read-failed",
170
+ message: boundedText(`Freshness comparison failed: ${error instanceof Error ? error.message : String(error)}`),
171
+ });
172
+ }
173
+ changes.sort((left, right) => compareCodeUnits(left.path, right.path) || compareCodeUnits(left.changeType, right.changeType));
174
+ unresolved.sort((left, right) => compareCodeUnits(left.path ?? "", right.path ?? "") || compareCodeUnits(left.reasonCode, right.reasonCode) || compareCodeUnits(left.message, right.message));
175
+ const modifiedCount = changes.filter((change) => change.changeType === "modified").length;
176
+ const missingCount = changes.length - modifiedCount;
177
+ const warnings = [];
178
+ if (changes.length > MAX_CHANGES)
179
+ warnings.push(`Change list truncated to ${MAX_CHANGES} of ${changes.length} entries.`);
180
+ if (unresolved.length > MAX_UNRESOLVED)
181
+ warnings.push(`Unresolved list truncated to ${MAX_UNRESOLVED} of ${unresolved.length} entries.`);
182
+ return {
183
+ schemaVersion: INDEX_FRESHNESS_SCHEMA_VERSION,
184
+ status: classifyIndexFreshness({ baselineSnapshotStatus, confirmedChangeCount: changes.length, unresolvedCount: unresolvedTotal }),
185
+ assessedAt,
186
+ baselineSnapshotStatus,
187
+ indexedFileCount: snapshot?.indexedFileCount ?? 0,
188
+ comparableFileCount: unchangedFileCount + modifiedCount,
189
+ unchangedFileCount,
190
+ changedFileCount: modifiedCount,
191
+ missingFileCount: missingCount,
192
+ unresolvedFileCount: unresolvedTotal,
193
+ changes: changes.slice(0, MAX_CHANGES),
194
+ changesTruncated: changes.length > MAX_CHANGES,
195
+ unresolved: unresolved.slice(0, MAX_UNRESOLVED),
196
+ unresolvedTruncated: unresolvedTotal > Math.min(unresolved.length, MAX_UNRESOLVED),
197
+ warnings,
198
+ };
199
+ }
@@ -0,0 +1,393 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { relativeWithinRoot, resolveWithinRoot } from "../core/pathSafety.js";
6
+ export const INDEX_SNAPSHOT_SCHEMA_VERSION = "my-dev-kit-lab-index-snapshot-v1";
7
+ const SUPPORTED_MANIFEST_ARTIFACT_KIND = "my-dev-kit-v1-manifest";
8
+ const SUPPORTED_MANIFEST_VERSION_PATTERN = /^1\./;
9
+ const MANIFEST_FILE = "manifest.json";
10
+ const MAX_ARTIFACT_ENTRIES = 512;
11
+ const MAX_UNRESOLVED_ENTRIES = 50;
12
+ const MAX_REPORTED_PATH_LENGTH = 260;
13
+ const HASH_CONCURRENCY = 32;
14
+ const MAX_TOOL_VERSION_LENGTH = 128;
15
+ const TOOL_VERSION_NOT_PROBED = {
16
+ name: "my-dev-kit",
17
+ version: null,
18
+ availability: "unavailable",
19
+ reason: "The my-dev-kit index manifest and index command output do not expose the tool version, and no --version probe result was supplied.",
20
+ };
21
+ /**
22
+ * Policy: turns the outcome of a `<kit-command> --version` probe into bounded tool evidence. Uses
23
+ * the first non-empty output line verbatim (no semver requirement); anything unsupported, empty,
24
+ * unprintable, or oversized is explicitly unavailable rather than guessed. Pure.
25
+ */
26
+ export function interpretToolVersionOutput(probe) {
27
+ const unavailable = (reason) => ({
28
+ name: "my-dev-kit",
29
+ version: null,
30
+ availability: "unavailable",
31
+ reason: boundedText(reason),
32
+ });
33
+ if (!probe.ok) {
34
+ const detail = probe.error ? `: ${probe.error}` : "";
35
+ return unavailable(`The --version probe did not succeed (exit code ${probe.exitCode ?? "none"})${detail}`);
36
+ }
37
+ const line = probe.stdout.split(/\r?\n/).map((entry) => entry.trim()).find((entry) => entry.length > 0);
38
+ if (line === undefined) {
39
+ return unavailable("The --version probe produced no output.");
40
+ }
41
+ if (/[\u0000-\u001f\u007f]/.test(line)) {
42
+ return unavailable("The --version probe reported a non-printable value.");
43
+ }
44
+ if (line.length > MAX_TOOL_VERSION_LENGTH) {
45
+ return unavailable(`The --version probe reported a value longer than ${MAX_TOOL_VERSION_LENGTH} characters.`);
46
+ }
47
+ return { name: "my-dev-kit", version: line, availability: "available", reason: null };
48
+ }
49
+ function isRecord(value) {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value);
51
+ }
52
+ /** Locale- and platform-independent ordering so snapshots never depend on enumeration order. */
53
+ export function compareCodeUnits(left, right) {
54
+ return left < right ? -1 : left > right ? 1 : 0;
55
+ }
56
+ function boundedText(value) {
57
+ return value.length > MAX_REPORTED_PATH_LENGTH ? `${value.slice(0, MAX_REPORTED_PATH_LENGTH)}...` : value;
58
+ }
59
+ /**
60
+ * Policy: validates the manifest against the supported my-dev-kit contract and the expected
61
+ * target. Pure; performs no filesystem access.
62
+ */
63
+ export function interpretIndexManifest(manifest, expected) {
64
+ const unsupported = (message) => ({ ok: false, code: "manifest-unsupported", message });
65
+ if (!isRecord(manifest)) {
66
+ return unsupported("Index manifest is not a JSON object.");
67
+ }
68
+ if (manifest.artifactKind !== SUPPORTED_MANIFEST_ARTIFACT_KIND) {
69
+ return unsupported(`Index manifest artifactKind is not ${SUPPORTED_MANIFEST_ARTIFACT_KIND}.`);
70
+ }
71
+ if (typeof manifest.version !== "string" || !SUPPORTED_MANIFEST_VERSION_PATTERN.test(manifest.version)) {
72
+ return unsupported("Index manifest version is missing or not a supported 1.x version.");
73
+ }
74
+ const artifacts = manifest.artifacts;
75
+ const symbolIndexPath = isRecord(artifacts) ? artifacts.symbolIndex : undefined;
76
+ if (typeof symbolIndexPath !== "string" || symbolIndexPath.length === 0) {
77
+ return unsupported("Index manifest does not name a symbolIndex artifact.");
78
+ }
79
+ if (typeof manifest.projectRoot !== "string" || manifest.projectRoot.length === 0) {
80
+ return unsupported("Index manifest does not record a projectRoot.");
81
+ }
82
+ const sourceRoots = manifest.sourceRoots;
83
+ if (!Array.isArray(sourceRoots) || !sourceRoots.every((root) => typeof root === "string")) {
84
+ return unsupported("Index manifest does not record sourceRoots.");
85
+ }
86
+ const sameSourceRoots = sourceRoots.length === expected.sourceRoots.length && sourceRoots.every((root, index) => root === expected.sourceRoots[index]);
87
+ if (!sameSourceRoots) {
88
+ return {
89
+ ok: false,
90
+ code: "manifest-target-mismatch",
91
+ message: `Index manifest source roots [${sourceRoots.join(", ")}] do not match [${expected.sourceRoots.join(", ")}].`,
92
+ };
93
+ }
94
+ const summary = manifest.summary;
95
+ const manifestFileCount = isRecord(summary) && typeof summary.fileCount === "number" ? summary.fileCount : null;
96
+ return {
97
+ ok: true,
98
+ contract: {
99
+ manifestArtifactKind: manifest.artifactKind,
100
+ manifestVersion: manifest.version,
101
+ createdAt: typeof manifest.createdAt === "string" ? manifest.createdAt : null,
102
+ symbolIndexRelativePath: symbolIndexPath,
103
+ manifestFileCount,
104
+ sourceRoots: [...sourceRoots],
105
+ projectRoot: manifest.projectRoot,
106
+ },
107
+ };
108
+ }
109
+ /**
110
+ * Policy: extracts the exact indexed-file path list from a symbol-index artifact. The count the
111
+ * index declares must agree with the list; duplicates are rejected rather than merged.
112
+ */
113
+ export function interpretSymbolIndexFiles(symbolIndex, manifestFileCount) {
114
+ const unsupported = (message) => ({ ok: false, code: "symbol-index-unsupported", message });
115
+ if (!isRecord(symbolIndex)) {
116
+ return unsupported("Symbol index is not a JSON object.");
117
+ }
118
+ if (typeof symbolIndex.schemaVersion !== "string") {
119
+ return unsupported("Symbol index does not record a schemaVersion.");
120
+ }
121
+ const files = symbolIndex.files;
122
+ if (!Array.isArray(files)) {
123
+ return unsupported("Symbol index does not list indexed files.");
124
+ }
125
+ const paths = [];
126
+ for (const entry of files) {
127
+ if (!isRecord(entry) || typeof entry.path !== "string" || entry.path.length === 0) {
128
+ return unsupported("Symbol index contains a file entry without a path.");
129
+ }
130
+ paths.push(entry.path);
131
+ }
132
+ if (new Set(paths).size !== paths.length) {
133
+ return unsupported("Symbol index lists the same file path more than once.");
134
+ }
135
+ const declaredCounts = [symbolIndex.fileCount, manifestFileCount].filter((count) => typeof count === "number");
136
+ if (declaredCounts.some((count) => count !== paths.length)) {
137
+ return {
138
+ ok: false,
139
+ code: "indexed-file-count-mismatch",
140
+ message: `Symbol index lists ${paths.length} files but the index declares ${declaredCounts.join("/")}.`,
141
+ };
142
+ }
143
+ return { ok: true, schemaVersion: symbolIndex.schemaVersion, paths };
144
+ }
145
+ /**
146
+ * Policy: normalizes an index-listed path to a target-relative forward-slash path and confirms it
147
+ * stays inside the target and one of the configured source roots. Pure.
148
+ */
149
+ export function resolveIndexedFilePath(targetRoot, sourceRoots, listedPath) {
150
+ const forward = listedPath.replace(/\\/g, "/");
151
+ if (forward.length === 0 || forward.includes("\0") || forward.startsWith("/") || /^[A-Za-z]:/.test(forward)) {
152
+ return { ok: false, reason: "invalid-path" };
153
+ }
154
+ try {
155
+ const absolute = resolveWithinRoot(targetRoot, forward);
156
+ const relativePath = relativeWithinRoot(targetRoot, absolute);
157
+ if (relativePath.length === 0) {
158
+ return { ok: false, reason: "invalid-path" };
159
+ }
160
+ const insideSourceRoot = sourceRoots.some((sourceRoot) => {
161
+ try {
162
+ relativeWithinRoot(resolveWithinRoot(targetRoot, sourceRoot), absolute);
163
+ return true;
164
+ }
165
+ catch {
166
+ return false;
167
+ }
168
+ });
169
+ return insideSourceRoot ? { ok: true, relativePath } : { ok: false, reason: "outside-target-or-source-roots" };
170
+ }
171
+ catch {
172
+ return { ok: false, reason: "outside-target-or-source-roots" };
173
+ }
174
+ }
175
+ async function hashFile(absolutePath) {
176
+ const hash = createHash("sha256");
177
+ let sizeBytes = 0;
178
+ for await (const chunk of createReadStream(absolutePath)) {
179
+ const buffer = chunk;
180
+ sizeBytes += buffer.length;
181
+ hash.update(buffer);
182
+ }
183
+ return { sha256: hash.digest("hex"), sizeBytes };
184
+ }
185
+ /** Reads and hashes one target-relative file without leaving the target (symlinks are resolved first). */
186
+ export async function snapshotIndexedFile(targetRoot, realTargetRoot, relativePath) {
187
+ const absolutePath = path.resolve(targetRoot, relativePath);
188
+ try {
189
+ // A symlink must not carry the read outside the target.
190
+ const realPath = await realpath(absolutePath);
191
+ try {
192
+ relativeWithinRoot(realTargetRoot, realPath);
193
+ }
194
+ catch {
195
+ return { ok: false, reason: "outside-target-or-source-roots" };
196
+ }
197
+ const stats = await stat(realPath);
198
+ if (!stats.isFile()) {
199
+ return { ok: false, reason: "not-a-file" };
200
+ }
201
+ const { sha256, sizeBytes } = await hashFile(realPath);
202
+ return { ok: true, file: { path: relativePath, sha256, sizeBytes, modifiedAt: stats.mtime.toISOString() } };
203
+ }
204
+ catch (error) {
205
+ return { ok: false, reason: error.code === "ENOENT" ? "missing" : "unreadable" };
206
+ }
207
+ }
208
+ async function enumerateIndexArtifacts(indexDir) {
209
+ const artifacts = [];
210
+ let truncated = false;
211
+ const pending = [indexDir];
212
+ while (pending.length > 0) {
213
+ const directory = pending.pop();
214
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
215
+ const absolutePath = path.join(directory, entry.name);
216
+ if (entry.isDirectory()) {
217
+ pending.push(absolutePath);
218
+ }
219
+ else if (entry.isFile()) {
220
+ if (artifacts.length >= MAX_ARTIFACT_ENTRIES) {
221
+ truncated = true;
222
+ continue;
223
+ }
224
+ artifacts.push({ path: relativeWithinRoot(indexDir, absolutePath), sizeBytes: (await stat(absolutePath)).size });
225
+ }
226
+ }
227
+ }
228
+ artifacts.sort((left, right) => compareCodeUnits(left.path, right.path));
229
+ return { artifacts, truncated };
230
+ }
231
+ async function readJson(filePath) {
232
+ let text;
233
+ try {
234
+ text = await readFile(filePath, "utf8");
235
+ }
236
+ catch (error) {
237
+ const missing = error.code === "ENOENT";
238
+ return { ok: false, missing, message: missing ? "file does not exist" : `file could not be read: ${error.message}` };
239
+ }
240
+ try {
241
+ return { ok: true, value: JSON.parse(text) };
242
+ }
243
+ catch (error) {
244
+ return { ok: false, missing: false, message: `file is not valid JSON: ${error.message}` };
245
+ }
246
+ }
247
+ /** Runs `fn` over `items` with bounded concurrency, returning results in input order. */
248
+ export async function mapInBatches(items, fn) {
249
+ const results = [];
250
+ for (let start = 0; start < items.length; start += HASH_CONCURRENCY) {
251
+ results.push(...(await Promise.all(items.slice(start, start + HASH_CONCURRENCY).map(fn))));
252
+ }
253
+ return results;
254
+ }
255
+ function unavailableSnapshot(command, failure, tool, manifest = null) {
256
+ return {
257
+ schemaVersion: INDEX_SNAPSHOT_SCHEMA_VERSION,
258
+ status: "unavailable",
259
+ unavailable: { code: failure.code, message: boundedText(failure.message) },
260
+ manifest,
261
+ tool: { ...tool },
262
+ indexCommand: summarizeIndexCommand(command),
263
+ indexedFileCount: 0,
264
+ files: [],
265
+ unresolvedFileCount: 0,
266
+ unresolvedFiles: [],
267
+ artifacts: [],
268
+ artifactsTruncated: false,
269
+ };
270
+ }
271
+ function summarizeIndexCommand(command) {
272
+ return { commandString: command.commandString, executable: command.executable, args: [...command.args] };
273
+ }
274
+ /**
275
+ * Captures bounded index-build evidence from an already built index: which files the index lists,
276
+ * their SHA-256 content identity, and the generated artifact inventory. Reads only the index
277
+ * directory and the listed files inside the target; runs no my-dev-kit command and never throws —
278
+ * an uninterpretable index yields an explicit `unavailable` snapshot instead of a guessed one.
279
+ * Performs no comparison and assigns no freshness.
280
+ */
281
+ export async function captureIndexSnapshot(options) {
282
+ const { indexDir, targetRoot, sourceRoots, command } = options;
283
+ const tool = options.tool ?? TOOL_VERSION_NOT_PROBED;
284
+ try {
285
+ const manifestRead = await readJson(path.join(indexDir, MANIFEST_FILE));
286
+ if (!manifestRead.ok) {
287
+ return unavailableSnapshot(command, {
288
+ ok: false,
289
+ code: manifestRead.missing ? "manifest-missing" : "manifest-unreadable",
290
+ message: `Index manifest ${manifestRead.message}.`,
291
+ }, tool);
292
+ }
293
+ const manifest = interpretIndexManifest(manifestRead.value, { sourceRoots });
294
+ if (!manifest.ok) {
295
+ return unavailableSnapshot(command, manifest, tool);
296
+ }
297
+ const { contract } = manifest;
298
+ const realTargetRoot = await realpath(targetRoot);
299
+ let manifestRoot;
300
+ try {
301
+ manifestRoot = await realpath(contract.projectRoot);
302
+ }
303
+ catch {
304
+ manifestRoot = path.resolve(contract.projectRoot);
305
+ }
306
+ if (path.relative(realTargetRoot, manifestRoot) !== "") {
307
+ return unavailableSnapshot(command, {
308
+ ok: false,
309
+ code: "manifest-target-mismatch",
310
+ message: "Index manifest projectRoot does not match the target root.",
311
+ }, tool);
312
+ }
313
+ let symbolIndexFile;
314
+ try {
315
+ symbolIndexFile = resolveWithinRoot(indexDir, contract.symbolIndexRelativePath);
316
+ }
317
+ catch {
318
+ return unavailableSnapshot(command, {
319
+ ok: false,
320
+ code: "manifest-unsupported",
321
+ message: "Index manifest symbolIndex path escapes the index directory.",
322
+ }, tool);
323
+ }
324
+ const symbolIndexRead = await readJson(symbolIndexFile);
325
+ if (!symbolIndexRead.ok) {
326
+ return unavailableSnapshot(command, {
327
+ ok: false,
328
+ code: symbolIndexRead.missing ? "symbol-index-missing" : "symbol-index-unreadable",
329
+ message: `Symbol index ${symbolIndexRead.message}.`,
330
+ }, tool);
331
+ }
332
+ const symbolIndex = interpretSymbolIndexFiles(symbolIndexRead.value, contract.manifestFileCount);
333
+ const manifestSummary = {
334
+ path: MANIFEST_FILE,
335
+ artifactKind: contract.manifestArtifactKind,
336
+ schemaVersion: contract.manifestVersion,
337
+ createdAt: contract.createdAt,
338
+ symbolIndexPath: relativeWithinRoot(indexDir, symbolIndexFile),
339
+ symbolIndexSchemaVersion: symbolIndex.ok ? symbolIndex.schemaVersion : "unknown",
340
+ };
341
+ if (!symbolIndex.ok) {
342
+ return unavailableSnapshot(command, symbolIndex, tool, manifestSummary);
343
+ }
344
+ const resolved = symbolIndex.paths.map((listed) => ({ listed, resolution: resolveIndexedFilePath(targetRoot, sourceRoots, listed) }));
345
+ const unresolved = [];
346
+ const toHash = [];
347
+ for (const { listed, resolution } of resolved) {
348
+ if (resolution.ok) {
349
+ toHash.push(resolution.relativePath);
350
+ }
351
+ else {
352
+ unresolved.push({ path: boundedText(listed), reason: resolution.reason });
353
+ }
354
+ }
355
+ const files = [];
356
+ for (let start = 0; start < toHash.length; start += HASH_CONCURRENCY) {
357
+ const batch = toHash.slice(start, start + HASH_CONCURRENCY);
358
+ const results = await Promise.all(batch.map((relativePath) => snapshotIndexedFile(targetRoot, realTargetRoot, relativePath)));
359
+ results.forEach((result, position) => {
360
+ if (result.ok) {
361
+ files.push(result.file);
362
+ }
363
+ else {
364
+ unresolved.push({ path: boundedText(batch[position]), reason: result.reason });
365
+ }
366
+ });
367
+ }
368
+ files.sort((left, right) => compareCodeUnits(left.path, right.path));
369
+ unresolved.sort((left, right) => compareCodeUnits(left.path, right.path) || compareCodeUnits(left.reason, right.reason));
370
+ const inventory = await enumerateIndexArtifacts(indexDir);
371
+ return {
372
+ schemaVersion: INDEX_SNAPSHOT_SCHEMA_VERSION,
373
+ status: unresolved.length === 0 ? "complete" : "partial",
374
+ unavailable: null,
375
+ manifest: manifestSummary,
376
+ tool: { ...tool },
377
+ indexCommand: summarizeIndexCommand(command),
378
+ indexedFileCount: symbolIndex.paths.length,
379
+ files,
380
+ unresolvedFileCount: unresolved.length,
381
+ unresolvedFiles: unresolved.slice(0, MAX_UNRESOLVED_ENTRIES),
382
+ artifacts: inventory.artifacts,
383
+ artifactsTruncated: inventory.truncated,
384
+ };
385
+ }
386
+ catch (error) {
387
+ return unavailableSnapshot(command, {
388
+ ok: false,
389
+ code: "snapshot-capture-failed",
390
+ message: `Index snapshot capture failed: ${error.message}`,
391
+ }, tool);
392
+ }
393
+ }
@@ -1,6 +1,8 @@
1
1
  import path from "node:path";
2
2
  import { countEstimatedTokens, countTextChars, tokenCountMethod } from "../core/countTokens.js";
3
3
  import { runMeasuredCommand } from "../core/runMeasuredCommand.js";
4
+ import { interpretToolVersionOutput } from "./indexSnapshot.js";
5
+ const VERSION_PROBE_TIMEOUT_MS = 30_000;
4
6
  function parseJsonIfPossible(text) {
5
7
  try {
6
8
  return JSON.parse(text);
@@ -89,6 +91,38 @@ export async function buildMyDevKitIndex(options) {
89
91
  command
90
92
  };
91
93
  }
94
+ /**
95
+ * Runs `<kit-command> --version` once through the shared measured-command runner and returns
96
+ * bounded tool evidence. Kept separate from `buildMyDevKitIndex` so it never counts toward index
97
+ * duration. An unsupported, failing, slow, or empty probe is explicit `unavailable` evidence; it
98
+ * never throws and never fails the caller.
99
+ */
100
+ export async function probeMyDevKitVersion(options) {
101
+ try {
102
+ const command = await runMeasuredCommand({
103
+ commandId: "version",
104
+ commandString: options.kitCommand,
105
+ cwd: process.cwd(),
106
+ outDir: options.commandsDir,
107
+ extraArgs: ["--version"],
108
+ timeoutMs: VERSION_PROBE_TIMEOUT_MS
109
+ });
110
+ return interpretToolVersionOutput({
111
+ ok: command.ok,
112
+ exitCode: command.exitCode,
113
+ stdout: command.stdout,
114
+ error: command.error
115
+ });
116
+ }
117
+ catch (error) {
118
+ return interpretToolVersionOutput({
119
+ ok: false,
120
+ exitCode: null,
121
+ stdout: "",
122
+ error: error instanceof Error ? error.message : String(error)
123
+ });
124
+ }
125
+ }
92
126
  /**
93
127
  * Runs search -> lookup -> slice -> source against an already prepared index. Never invokes
94
128
  * `index`; `durationMs` covers retrieval only.
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { resolveWithinRoot } from "../../../core/pathSafety.js";
3
+ import { assessIndexFreshness } from "../../../evaluation/indexFreshness.js";
3
4
  import { runMyDevKitRetrievalFromIndex } from "../../../evaluation/runMyDevKitRetrieval.js";
4
5
  import { runRawFullFileBaseline } from "../../../evaluation/runRawFullFileBaseline.js";
5
6
  import { groupWarmIndexCases, taskOutputSegment } from "./selection.js";
@@ -90,8 +91,15 @@ async function executeTask(args) {
90
91
  }
91
92
  let warmRetrieval;
92
93
  let warmStatus = "failed";
94
+ let indexFreshness;
93
95
  if (!args.session) {
94
96
  errors.push(args.setupFailure ?? { side: "warm", code: "warm-index-setup-failed", message: "No warm index session." });
97
+ // No snapshot exists, so freshness is conservatively unknown rather than assumed.
98
+ indexFreshness = await assessIndexFreshness({
99
+ snapshot: null,
100
+ targetRoot: evaluationCase.absoluteTargetRoot,
101
+ sourceRoots: evaluationCase.sourceRoots,
102
+ });
95
103
  }
96
104
  else {
97
105
  try {
@@ -101,6 +109,12 @@ async function executeTask(args) {
101
109
  errors.push({ side: "warm", code: "warm-session-target-mismatch", message: errorMessage(error) });
102
110
  }
103
111
  if (!errors.some((error) => error.side === "warm")) {
112
+ // Observational only: measured immediately before reuse, and retrieval below runs regardless.
113
+ indexFreshness = await assessIndexFreshness({
114
+ snapshot: args.session.indexSnapshot,
115
+ targetRoot: args.session.targetRoot,
116
+ sourceRoots: args.session.sourceRoots,
117
+ });
104
118
  try {
105
119
  // requireKit: false retains subordinate command evidence; a skipped result caused by a
106
120
  // failed command is classified as failed below rather than as an honest skip.
@@ -134,6 +148,7 @@ async function executeTask(args) {
134
148
  warmStatus,
135
149
  rawBaseline,
136
150
  warmRetrieval,
151
+ indexFreshness,
137
152
  warnings,
138
153
  errors,
139
154
  };
@@ -46,6 +46,7 @@ function summarizeTask(task) {
46
46
  commands: warm.commands.map(summarizeCommand),
47
47
  }
48
48
  : null,
49
+ indexFreshness: task.indexFreshness ? structuredClone(task.indexFreshness) : null,
49
50
  warnings: [...task.warnings],
50
51
  errors: task.errors.map((error) => ({ ...error })),
51
52
  };
@@ -62,6 +63,7 @@ export function summarizeProjectExecution(project) {
62
63
  sessionPrepared: project.session !== undefined,
63
64
  buildDurationMs: project.build ? project.build.durationMs : null,
64
65
  indexCommand: project.build ? summarizeCommand(project.build.command) : null,
66
+ indexSnapshot: project.session ? structuredClone(project.session.indexSnapshot) : null,
65
67
  tasks: project.tasks.map(summarizeTask),
66
68
  warnings: [...project.warnings],
67
69
  errors: [...project.errors],
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { buildMyDevKitIndex } from "../../../evaluation/runMyDevKitRetrieval.js";
3
+ import { buildMyDevKitIndex, probeMyDevKitVersion } from "../../../evaluation/runMyDevKitRetrieval.js";
4
+ import { captureIndexSnapshot } from "../../../evaluation/indexSnapshot.js";
4
5
  /**
5
6
  * Builds the index once through the shared `buildMyDevKitIndex` owner. A build that reports
6
7
  * success but leaves no index directory is not treated as a prepared session.
@@ -17,12 +18,26 @@ export async function prepareWarmIndexSession(options) {
17
18
  }
18
19
  return { ok: false, warnings: [...build.warnings, message], build };
19
20
  }
21
+ const sourceRoots = Object.freeze([...options.target.sourceRoots]);
22
+ // One `--version` probe per prepared session, outside the measured index build; it never fails
23
+ // the session (an unsupported probe becomes explicit `unavailable` tool evidence).
24
+ const tool = await probeMyDevKitVersion({ kitCommand: options.kitCommand, commandsDir: options.commandsDir });
25
+ // Reads the just-built index and the files it lists; never invokes my-dev-kit again and never
26
+ // fails the session (an uninterpretable index becomes an explicit `unavailable` snapshot).
27
+ const indexSnapshot = await captureIndexSnapshot({
28
+ indexDir: build.indexDir,
29
+ targetRoot: options.target.absoluteTargetRoot,
30
+ sourceRoots,
31
+ command: build.command,
32
+ tool
33
+ });
20
34
  const session = Object.freeze({
21
35
  indexDir: build.indexDir,
22
36
  buildDurationMs: build.durationMs,
23
37
  targetRoot: options.target.absoluteTargetRoot,
24
- sourceRoots: Object.freeze([...options.target.sourceRoots]),
25
- buildCommand: build.command
38
+ sourceRoots,
39
+ buildCommand: build.command,
40
+ indexSnapshot
26
41
  });
27
42
  return { ok: true, session, build };
28
43
  }
@@ -97,6 +97,16 @@ function buildFindings(run) {
97
97
  ...skipped,
98
98
  ];
99
99
  }
100
+ /** Neutral, count-only sentence derived from the report's persisted freshness summary. */
101
+ function freshnessSentence(report) {
102
+ const summary = report.indexFreshnessSummary;
103
+ if (!summary || summary.assessedTaskCount === 0) {
104
+ return "No per-task index freshness assessment was available in this report.";
105
+ }
106
+ return (`Index freshness was assessed for ${summary.assessedTaskCount} task boundar${summary.assessedTaskCount === 1 ? "y" : "ies"}: ` +
107
+ `fresh=${summary.freshTaskCount}, stale=${summary.staleTaskCount}, partially-stale=${summary.partiallyStaleTaskCount}, unknown=${summary.unknownTaskCount}. ` +
108
+ "Freshness is observational evidence over files represented by the index snapshot and does not change retrieval or provider status.");
109
+ }
100
110
  function buildInterpretation(run, contextStrategyComparisonV043, warmIndexReuse) {
101
111
  if (run.pluginId === "warm-index-reuse" && warmIndexReuse) {
102
112
  const summary = warmIndexReuse.summary;
@@ -107,7 +117,8 @@ function buildInterpretation(run, contextStrategyComparisonV043, warmIndexReuse)
107
117
  "The report separates one-time index-build duration from per-task retrieval duration and shows how the fixed build cost is amortized across repeated tasks. " +
108
118
  `Deterministic fake-agent correctness/token evidence is available for ${summary.agentCorrectnessAvailableCount} of ${summary.agentSideCount} task sides (correctness) and ${summary.agentTotalTokensAvailableCount} (total tokens); ` +
109
119
  "fake-agent totals are simulated harness evidence, not real-model or provider measurements. " +
110
- "Estimated context-token values are context-size estimates, not provider token usage.",
120
+ "Estimated context-token values are context-size estimates, not provider token usage. " +
121
+ freshnessSentence(warmIndexReuse),
111
122
  recommendedNextStep: run.status === "completed"
112
123
  ? "Review per-task measurements and cumulative component costs for each project; the variants are reported side by side and are not ranked."
113
124
  : "Inspect unavailable metrics, warnings, and failures before drawing conclusions from this run.",
@@ -132,7 +143,8 @@ function buildInterpretation(run, contextStrategyComparisonV043, warmIndexReuse)
132
143
  `Agent evidence status: ${campaign.agentEvidenceStatus} (${campaign.executedSideCount} of ${campaign.scheduledSideCount} scheduled sides executed` +
133
144
  `${nonCompletedCounts ? `; outcome counts: ${nonCompletedCounts}` : ""}). ` +
134
145
  `Token evidence status: ${campaign.tokenEvidenceStatus} (${summary.agentTotalTokensAvailableCount} of ${summary.agentSideCount} task sides have a total-token value). ` +
135
- "Context estimated tokens remain a separate character-based context-size estimate and are never substituted for provider telemetry.",
146
+ "Context estimated tokens remain a separate character-based context-size estimate and are never substituted for provider telemetry. " +
147
+ freshnessSentence(warmIndexReuse),
136
148
  recommendedNextStep: isInfrastructureComplete && isAgentEvidenceComplete
137
149
  ? "Review per-task correctness, token provenance, and cumulative component measurements."
138
150
  : "Review infrastructure gaps (index build, raw/warm retrieval) separately from provider/agent limitations before drawing conclusions from this run.",
@@ -1,4 +1,4 @@
1
- import { WARM_INDEX_REUSE_REPORT_SCHEMA_VERSION, } from "./warmIndexReuseReportModel.js";
1
+ import { MAX_REPORT_FRESHNESS_CHANGES, MAX_REPORT_FRESHNESS_UNRESOLVED, WARM_INDEX_REUSE_REPORT_SCHEMA_VERSION, } from "./warmIndexReuseReportModel.js";
2
2
  const WARM_INDEX_REUSE_PLUGIN_ID = "warm-index-reuse";
3
3
  const COMMON_LIMITATIONS = [
4
4
  "Context estimated-token counts use the character-based estimate and are not provider billing telemetry.",
@@ -23,6 +23,13 @@ const CLAUDE_CAMPAIGN_LIMITATIONS = [
23
23
  "Token totals may be unavailable for some CLI/version/run outcomes.",
24
24
  "Unavailable token totals remain unavailable and are never replaced by zero or a context estimate.",
25
25
  ];
26
+ const FRESHNESS_LIMITATIONS = [
27
+ "Freshness compares only files proven by the index snapshot to have been indexed. It does not establish that the entire repository is unchanged.",
28
+ "A new file that was not represented in the snapshot is outside the current freshness comparison and is not automatically evidence that the index is stale.",
29
+ "File content SHA-256 is the comparison identity. Modified timestamps are diagnostic metadata only.",
30
+ "Freshness is observational evidence. It does not trigger reindexing, suppress warm retrieval, alter execution status, or alter provider/agent outcome status.",
31
+ "Freshness statuses: fresh means all represented indexed files were completely compared and their content identities still match; stale means a complete comparison confirmed at least one represented indexed file is modified or missing; partially-stale means at least one represented indexed file is confirmed changed but comparison evidence is incomplete; unknown means no confirmed change established staleness but evidence is insufficient to prove freshness. A task without an assessment is reported as not assessed, which is not the same as unknown.",
32
+ ];
26
33
  const COST_MODEL = [
27
34
  "Index construction is a one-time cost per benchmark project; task retrieval is the repeated warm cost.",
28
35
  "Cold start: for the first task in a project, the cumulative warm component duration is the one-time index build plus the first retrieval.",
@@ -90,6 +97,7 @@ export function buildWarmIndexReuseReport(run) {
90
97
  warm: task.warm,
91
98
  rawAgent: toReportAgent(rawEvidence),
92
99
  warmAgent: toReportAgent(warmEvidence),
100
+ indexFreshness: toReportFreshness(execution.tasks[taskIndex].indexFreshness),
93
101
  };
94
102
  }),
95
103
  };
@@ -121,6 +129,49 @@ export function buildWarmIndexReuseReport(run) {
121
129
  projects,
122
130
  agent: agentIdentity,
123
131
  agentCampaign,
132
+ indexFreshnessSummary: summarizeFreshness(projects.flatMap((project) => project.tasks)),
133
+ };
134
+ }
135
+ function boundedList(source, limit, map) {
136
+ const items = source.slice(0, limit).map(map);
137
+ return { totalCount: source.length, displayedCount: items.length, omittedCount: source.length - items.length, items };
138
+ }
139
+ /**
140
+ * Presents persisted freshness evidence only: it never recomputes a status, count, or hash and
141
+ * never touches the filesystem. Absent evidence stays null rather than becoming `unknown`.
142
+ */
143
+ function toReportFreshness(freshness) {
144
+ if (!freshness)
145
+ return null;
146
+ return {
147
+ status: freshness.status,
148
+ assessedAt: freshness.assessedAt,
149
+ baselineSnapshotStatus: freshness.baselineSnapshotStatus,
150
+ indexedFileCount: freshness.indexedFileCount,
151
+ comparableFileCount: freshness.comparableFileCount,
152
+ unchangedFileCount: freshness.unchangedFileCount,
153
+ changedFileCount: freshness.changedFileCount,
154
+ missingFileCount: freshness.missingFileCount,
155
+ unresolvedFileCount: freshness.unresolvedFileCount,
156
+ changes: boundedList(freshness.changes, MAX_REPORT_FRESHNESS_CHANGES, (change) => ({ path: change.path, changeType: change.changeType })),
157
+ unresolved: boundedList(freshness.unresolved, MAX_REPORT_FRESHNESS_UNRESOLVED, (entry) => ({
158
+ path: entry.path,
159
+ reasonCode: entry.reasonCode,
160
+ message: entry.message,
161
+ })),
162
+ warnings: [...freshness.warnings],
163
+ };
164
+ }
165
+ function summarizeFreshness(tasks) {
166
+ const assessed = tasks.flatMap((task) => (task.indexFreshness ? [task.indexFreshness] : []));
167
+ const count = (status) => assessed.filter((freshness) => freshness.status === status).length;
168
+ return {
169
+ assessedTaskCount: assessed.length,
170
+ unassessedTaskCount: tasks.length - assessed.length,
171
+ freshTaskCount: count("fresh"),
172
+ staleTaskCount: count("stale"),
173
+ partiallyStaleTaskCount: count("partially-stale"),
174
+ unknownTaskCount: count("unknown"),
124
175
  };
125
176
  }
126
177
  function deriveAgentIdentity(args) {
@@ -191,10 +242,10 @@ function buildCampaignSummary(args) {
191
242
  }
192
243
  function buildLimitations(agent) {
193
244
  if (agent.mode === "deterministic-fake") {
194
- return [...COMMON_LIMITATIONS, ...FAKE_AGENT_LIMITATIONS];
245
+ return [...COMMON_LIMITATIONS, ...FAKE_AGENT_LIMITATIONS, ...FRESHNESS_LIMITATIONS];
195
246
  }
196
247
  const providerLimitations = agent.id === "codex" ? CODEX_CAMPAIGN_LIMITATIONS : CLAUDE_CAMPAIGN_LIMITATIONS;
197
- return [...COMMON_LIMITATIONS, ...providerLimitations];
248
+ return [...COMMON_LIMITATIONS, ...providerLimitations, ...FRESHNESS_LIMITATIONS];
198
249
  }
199
250
  function toReportAgent(agent) {
200
251
  if (!agent)
@@ -301,6 +301,7 @@ function renderWarmIndexReuseSection(lines, section) {
301
301
  lines.push(fieldLine("Agent Mode", section.agent.mode));
302
302
  lines.push(fieldLine("Agent Correctness Available (task sides)", `${section.summary.agentCorrectnessAvailableCount} of ${section.summary.agentSideCount}`));
303
303
  lines.push(fieldLine("Agent Token Totals Available (task sides)", `${section.summary.agentTotalTokensAvailableCount} of ${section.summary.agentSideCount}`));
304
+ renderFreshnessSummary(lines, section);
304
305
  if (section.agentCampaign) {
305
306
  renderCampaignSection(lines, section.agentCampaign);
306
307
  }
@@ -324,6 +325,7 @@ function renderWarmIndexReuseSection(lines, section) {
324
325
  const rows = [
325
326
  ["Raw Status", task.rawStatus],
326
327
  ["Warm Status", task.warmStatus],
328
+ ["Index Freshness Status", task.indexFreshness ? task.indexFreshness.status : "not assessed"],
327
329
  ["Raw Context Characters", formatWarmMetric(task.raw.contextCharacters)],
328
330
  ["Warm Context Characters", formatWarmMetric(task.warm.contextCharacters)],
329
331
  ["Raw Estimated Context Tokens (estimate)", formatWarmMetric(task.raw.contextEstimatedTokens)],
@@ -347,9 +349,62 @@ function renderWarmIndexReuseSection(lines, section) {
347
349
  for (const [label, value] of rows) {
348
350
  lines.push(fieldLine(label, value));
349
351
  }
352
+ renderTaskFreshnessDetail(lines, task.indexFreshness);
350
353
  }
351
354
  });
352
355
  }
356
+ function renderFreshnessSummary(lines, section) {
357
+ // Reports serialized before v0.6.0 Batch 3 lack the summary; render them as having no assessment.
358
+ const summary = section.indexFreshnessSummary ?? {
359
+ assessedTaskCount: 0,
360
+ unassessedTaskCount: section.summary.taskCount,
361
+ freshTaskCount: 0,
362
+ staleTaskCount: 0,
363
+ partiallyStaleTaskCount: 0,
364
+ unknownTaskCount: 0,
365
+ };
366
+ pushSection(lines, "Index Freshness Summary");
367
+ lines.push(fieldLine("Assessed Tasks", summary.assessedTaskCount));
368
+ lines.push(fieldLine("Unassessed Tasks", summary.unassessedTaskCount));
369
+ lines.push(fieldLine("Fresh Tasks", summary.freshTaskCount));
370
+ lines.push(fieldLine("Stale Tasks", summary.staleTaskCount));
371
+ lines.push(fieldLine("Partially-Stale Tasks", summary.partiallyStaleTaskCount));
372
+ lines.push(fieldLine("Unknown Tasks", summary.unknownTaskCount));
373
+ }
374
+ function pushFreshnessBoundedNote(lines, list) {
375
+ if (list.omittedCount > 0) {
376
+ lines.push(`Displayed: ${list.displayedCount} of ${list.totalCount}`);
377
+ lines.push(`Omitted: ${list.omittedCount}`);
378
+ }
379
+ }
380
+ /** Renders the persisted freshness counts and bounded evidence; hashes are never printed. */
381
+ function renderTaskFreshnessDetail(lines, freshness) {
382
+ if (!freshness)
383
+ return;
384
+ lines.push(fieldLine("Baseline Snapshot Status", freshness.baselineSnapshotStatus));
385
+ lines.push(fieldLine("Indexed Files", freshness.indexedFileCount));
386
+ lines.push(fieldLine("Comparable Files", freshness.comparableFileCount));
387
+ lines.push(fieldLine("Unchanged Files", freshness.unchangedFileCount));
388
+ lines.push(fieldLine("Modified Files", freshness.changedFileCount));
389
+ lines.push(fieldLine("Missing Files", freshness.missingFileCount));
390
+ lines.push(fieldLine("Unresolved Comparisons", freshness.unresolvedFileCount));
391
+ lines.push("Freshness Changes:");
392
+ pushFreshnessBoundedNote(lines, freshness.changes);
393
+ if (freshness.changes.items.length === 0) {
394
+ lines.push("- none");
395
+ }
396
+ for (const change of freshness.changes.items) {
397
+ lines.push(`- ${sanitizeScalar(change.changeType)} ${sanitizeScalar(change.path)}`);
398
+ }
399
+ lines.push("Freshness Unresolved Evidence:");
400
+ pushFreshnessBoundedNote(lines, freshness.unresolved);
401
+ if (freshness.unresolved.items.length === 0) {
402
+ lines.push("- none");
403
+ }
404
+ for (const entry of freshness.unresolved.items) {
405
+ lines.push(`- ${entry.path === null ? "run-level" : sanitizeScalar(entry.path)} [${sanitizeScalar(entry.reasonCode)}] ${sanitizeScalar(entry.message)}`);
406
+ }
407
+ }
353
408
  export function renderPluginExperimentReportText(report) {
354
409
  const lines = [];
355
410
  pushSection(lines, "Plugin Experiment Report");
@@ -25,6 +25,7 @@ export function renderWarmIndexReuseHtml(section) {
25
25
  ["Agent correctness available", `${section.summary.agentCorrectnessAvailableCount} of ${section.summary.agentSideCount}`],
26
26
  ["Agent token totals available", `${section.summary.agentTotalTokensAvailableCount} of ${section.summary.agentSideCount}`],
27
27
  ])}
28
+ ${renderFreshnessSummary(section.indexFreshnessSummary ?? legacyFreshnessSummary(section.summary.taskCount))}
28
29
  ${section.agentCampaign ? renderCampaignSection(section.agentCampaign) : ""}
29
30
  <h3>Cold Start And Warm Reuse</h3>
30
31
  ${list(section.costModel)}
@@ -33,6 +34,73 @@ export function renderWarmIndexReuseHtml(section) {
33
34
  ${section.projects.length === 0 ? "<p>No warm-index project evidence was recorded.</p>" : section.projects.map((project, index) => renderProject(project, index, section.agent.id)).join("\n")}
34
35
  </section>`;
35
36
  }
37
+ // Reports serialized before v0.6.0 Batch 3 lack the summary; they are rendered as having no assessment.
38
+ function legacyFreshnessSummary(taskCount) {
39
+ return {
40
+ assessedTaskCount: 0,
41
+ unassessedTaskCount: taskCount,
42
+ freshTaskCount: 0,
43
+ staleTaskCount: 0,
44
+ partiallyStaleTaskCount: 0,
45
+ unknownTaskCount: 0,
46
+ };
47
+ }
48
+ function renderFreshnessSummary(summary) {
49
+ return `<h3>Index Freshness</h3>
50
+ ${table(["Field", "Value"], [
51
+ ["Assessed tasks", String(summary.assessedTaskCount)],
52
+ ["Unassessed tasks", String(summary.unassessedTaskCount)],
53
+ ["Fresh tasks", String(summary.freshTaskCount)],
54
+ ["Stale tasks", String(summary.staleTaskCount)],
55
+ ["Partially-stale tasks", String(summary.partiallyStaleTaskCount)],
56
+ ["Unknown tasks", String(summary.unknownTaskCount)],
57
+ ])}`;
58
+ }
59
+ /** Persisted freshness evidence per task; hashes are deliberately absent from this presentation. */
60
+ function renderProjectFreshness(tasks) {
61
+ const overview = table(["Task", "Freshness", "Baseline", "Indexed", "Comparable", "Unchanged", "Modified", "Missing", "Unresolved"], tasks.map((task) => {
62
+ const freshness = task.indexFreshness;
63
+ const label = `${task.taskOrdinal}. ${task.caseId}`;
64
+ return freshness
65
+ ? [
66
+ label,
67
+ freshness.status,
68
+ freshness.baselineSnapshotStatus,
69
+ String(freshness.indexedFileCount),
70
+ String(freshness.comparableFileCount),
71
+ String(freshness.unchangedFileCount),
72
+ String(freshness.changedFileCount),
73
+ String(freshness.missingFileCount),
74
+ String(freshness.unresolvedFileCount),
75
+ ]
76
+ : [label, "not assessed", "unavailable", "", "", "", "", "", ""];
77
+ }));
78
+ const detailRows = tasks.flatMap((task) => {
79
+ const freshness = task.indexFreshness;
80
+ if (!freshness)
81
+ return [];
82
+ const label = `${task.taskOrdinal}. ${task.caseId}`;
83
+ return [
84
+ ...freshness.changes.items.map((change) => [label, change.changeType, change.path, ""]),
85
+ ...freshness.unresolved.items.map((entry) => [label, "unresolved", entry.path ?? "run-level", `${entry.reasonCode}: ${entry.message}`]),
86
+ ];
87
+ });
88
+ const omitted = tasks.flatMap((task) => {
89
+ const freshness = task.indexFreshness;
90
+ if (!freshness)
91
+ return [];
92
+ return [
93
+ ...(freshness.changes.omittedCount > 0 ? [`Task ${task.taskOrdinal}: ${freshness.changes.omittedCount} of ${freshness.changes.totalCount} changes omitted.`] : []),
94
+ ...(freshness.unresolved.omittedCount > 0
95
+ ? [`Task ${task.taskOrdinal}: ${freshness.unresolved.omittedCount} of ${freshness.unresolved.totalCount} unresolved entries omitted.`]
96
+ : []),
97
+ ];
98
+ });
99
+ return `<h4>Index Freshness</h4>
100
+ ${overview}
101
+ ${detailRows.length === 0 ? "" : table(["Task", "Evidence type", "Path", "Detail"], detailRows)}
102
+ ${omitted.length === 0 ? "" : list(omitted)}`;
103
+ }
36
104
  function renderCampaignSection(campaign) {
37
105
  return `<h3>Real-Agent Campaign</h3>
38
106
  ${table(["Field", "Value"], [
@@ -115,6 +183,7 @@ function renderProject(project, index, agentId) {
115
183
  formatMetric(task.raw.cumulativeEstimatedContextTokens),
116
184
  formatMetric(task.warm.cumulativeEstimatedContextTokens),
117
185
  ]))}
186
+ ${renderProjectFreshness(project.tasks)}
118
187
  <p class="muted">Estimated tokens are character-based context-size estimates, not provider token usage.</p>
119
188
  <p class="muted">${agentColumnsNote(agentId)}</p>
120
189
  ${table([
@@ -1 +1,4 @@
1
1
  export const WARM_INDEX_REUSE_REPORT_SCHEMA_VERSION = "my-dev-kit-lab-warm-index-report-v1";
2
+ /** Report presentation limits; the full persisted lists stay in warm-index-execution.json. */
3
+ export const MAX_REPORT_FRESHNESS_CHANGES = 20;
4
+ export const MAX_REPORT_FRESHNESS_UNRESOLVED = 10;
package/docs/METRICS.md CHANGED
@@ -535,3 +535,21 @@ Available in the installed v0.5.2 CLI. A `--campaign` run replaces the determini
535
535
  Caveat: Claude and Codex token exposure depends on that provider's actual CLI output for the run; token evidence is reported as `unavailable`/`partial` explicitly rather than assumed. No fixed claim that either provider always or never exposes a token total is made — `tokenEvidenceStatus` is the authoritative, per-run signal.
536
536
 
537
537
  No token-savings percentage, break-even task, winner, ranking, or provider billing metric is calculated for a campaign run, matching the fake-agent path above.
538
+
539
+ ### Index freshness evidence (v0.6.0)
540
+
541
+ Status: released in v0.6.0 and present in the current package. The freshness values below are report evidence, not metric formulas: no `ExperimentMetric` ID, plot, or gallery item is added for them.
542
+
543
+ Before each task's warm retrieval, the warm-index run compares the files the index snapshot proves were indexed against their current state and persists one assessment per task (`indexFreshness` in `warm-index-execution.json`). The report only presents that persisted assessment; it never re-reads the filesystem, re-hashes files, or invokes my-dev-kit.
544
+
545
+ - **Snapshot-scoped comparison.** Only files listed by the index snapshot are compared. Freshness does not establish that the whole repository is unchanged.
546
+ - **SHA-256 is the authority.** A file is modified when its current SHA-256 differs from the snapshot's, or missing when it no longer exists. A changed modified time with identical content is not a change; timestamps are diagnostic metadata only.
547
+ - **New and non-indexed files.** A file that was not represented in the snapshot is outside the comparison and is not, by itself, evidence that the index is stale. Lab does not guess which files my-dev-kit would index.
548
+ - **Four statuses.**
549
+ - `fresh`: the snapshot was complete, every represented file was compared, and every content identity still matches.
550
+ - `stale`: a complete comparison confirmed at least one represented file is modified or missing.
551
+ - `partially-stale`: at least one represented file is confirmed changed, but comparison evidence is incomplete (a partial snapshot or unresolved comparisons).
552
+ - `unknown`: no confirmed change established staleness, but evidence is insufficient to prove freshness (an unavailable or partial snapshot, or an unresolved comparison). It is never treated as `fresh`.
553
+ - **`unknown` versus not assessed.** `unknown` means an assessment ran and could not prove freshness. A task with no assessment (for example a report from before v0.6.0) is shown as `not assessed` and counted as unassessed, never as `unknown`.
554
+ - **Report-level counts.** `report.warmIndexReuse.indexFreshnessSummary` holds direct presentation counts of assessed, unassessed, `fresh`, `stale`, `partially-stale`, and `unknown` tasks (assessed equals the sum of the four statuses). They are counts, not `ExperimentMetric` formulas: no percentage, score, or ranking is calculated. Each task's `indexFreshness` lists at most 20 changed files and 10 unresolved comparisons, with the persisted totals and the omitted count; hashes stay in `warm-index-execution.json`.
555
+ - **Observational only.** Freshness does not trigger reindexing, suppress or alter warm retrieval, or change execution status, correctness, provider or agent outcome status, or token evidence. v0.6.0 makes no reindex recommendation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dailephd/my-dev-kit-lab",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Evidence, benchmark, and evaluation companion for my-dev-kit.",
6
6
  "bin": {