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

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.
@@ -1,5 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { resolveWithinRoot } from "../../../core/pathSafety.js";
3
+ import { assessAffectedNeighborhood, mapAffectedNeighborhoodSeeds, } from "../../../evaluation/affectedNeighborhood.js";
4
+ import { assessIndexFreshness } from "../../../evaluation/indexFreshness.js";
3
5
  import { runMyDevKitRetrievalFromIndex } from "../../../evaluation/runMyDevKitRetrieval.js";
4
6
  import { runRawFullFileBaseline } from "../../../evaluation/runRawFullFileBaseline.js";
5
7
  import { groupWarmIndexCases, taskOutputSegment } from "./selection.js";
@@ -90,8 +92,16 @@ async function executeTask(args) {
90
92
  }
91
93
  let warmRetrieval;
92
94
  let warmStatus = "failed";
95
+ let indexFreshness;
96
+ let affectedNeighborhood = null;
93
97
  if (!args.session) {
94
98
  errors.push(args.setupFailure ?? { side: "warm", code: "warm-index-setup-failed", message: "No warm index session." });
99
+ // No snapshot exists, so freshness is conservatively unknown rather than assumed.
100
+ indexFreshness = await assessIndexFreshness({
101
+ snapshot: null,
102
+ targetRoot: evaluationCase.absoluteTargetRoot,
103
+ sourceRoots: evaluationCase.sourceRoots,
104
+ });
95
105
  }
96
106
  else {
97
107
  try {
@@ -101,6 +111,30 @@ async function executeTask(args) {
101
111
  errors.push({ side: "warm", code: "warm-session-target-mismatch", message: errorMessage(error) });
102
112
  }
103
113
  if (!errors.some((error) => error.side === "warm")) {
114
+ // Observational only: measured immediately before reuse, and retrieval below runs regardless.
115
+ indexFreshness = await assessIndexFreshness({
116
+ snapshot: args.session.indexSnapshot,
117
+ targetRoot: args.session.targetRoot,
118
+ sourceRoots: args.session.sourceRoots,
119
+ });
120
+ // Observational and in-memory: this task's freshness against the shared baseline graph. It
121
+ // reads no files, runs no my-dev-kit command, and cannot stop the retrieval below.
122
+ try {
123
+ const seedMapping = mapAffectedNeighborhoodSeeds({
124
+ indexSnapshot: args.session.indexSnapshot,
125
+ freshness: indexFreshness,
126
+ graph: args.session.affectedNeighborhoodGraph,
127
+ });
128
+ affectedNeighborhood = assessAffectedNeighborhood({
129
+ graph: args.session.affectedNeighborhoodGraph,
130
+ seedMapping,
131
+ task: evaluationCase,
132
+ });
133
+ }
134
+ catch (error) {
135
+ affectedNeighborhood = null;
136
+ warnings.push(`Affected-neighborhood assessment was not recorded: ${errorMessage(error)}`);
137
+ }
104
138
  try {
105
139
  // requireKit: false retains subordinate command evidence; a skipped result caused by a
106
140
  // failed command is classified as failed below rather than as an honest skip.
@@ -134,6 +168,8 @@ async function executeTask(args) {
134
168
  warmStatus,
135
169
  rawBaseline,
136
170
  warmRetrieval,
171
+ indexFreshness,
172
+ affectedNeighborhood,
137
173
  warnings,
138
174
  errors,
139
175
  };
@@ -46,6 +46,8 @@ function summarizeTask(task) {
46
46
  commands: warm.commands.map(summarizeCommand),
47
47
  }
48
48
  : null,
49
+ indexFreshness: task.indexFreshness ? structuredClone(task.indexFreshness) : null,
50
+ affectedNeighborhood: task.affectedNeighborhood ? structuredClone(task.affectedNeighborhood) : null,
49
51
  warnings: [...task.warnings],
50
52
  errors: task.errors.map((error) => ({ ...error })),
51
53
  };
@@ -62,6 +64,7 @@ export function summarizeProjectExecution(project) {
62
64
  sessionPrepared: project.session !== undefined,
63
65
  buildDurationMs: project.build ? project.build.durationMs : null,
64
66
  indexCommand: project.build ? summarizeCommand(project.build.command) : null,
67
+ indexSnapshot: project.session ? structuredClone(project.session.indexSnapshot) : null,
65
68
  tasks: project.tasks.map(summarizeTask),
66
69
  warnings: [...project.warnings],
67
70
  errors: [...project.errors],
@@ -147,6 +147,7 @@ export function calculateWarmIndexProjectMetrics(project, agentEvidence) {
147
147
  cumulativeComponentDurationMs: warmDuration.add(warm.retrievalDurationMs, label("warm retrieval duration")),
148
148
  cumulativeEstimatedContextTokens: warmTokens.add(warm.contextEstimatedTokens, label("warm estimated context tokens")),
149
149
  ...warmAgent,
150
+ ...affectedNeighborhoodMetrics(task),
150
151
  cumulativeAgentTotalTokens: warmAgentTokens.add(warmAgent.agentTotalTokens, label("warm agent total tokens")),
151
152
  },
152
153
  };
@@ -192,6 +193,35 @@ function warmDirect(task) {
192
193
  retrievalDurationMs: availableMetric(warm.durationMs, "ms", "measured"),
193
194
  };
194
195
  }
196
+ const NO_AFFECTED_NEIGHBORHOOD_REASON = "No affected-neighborhood assessment was recorded for this task.";
197
+ /**
198
+ * Converts the persisted affected-neighborhood assessment into the six warm-side metrics, once.
199
+ * A finite value (including zero) is available; an absent assessment or a null field is
200
+ * unavailable with a reason, never zero. The metric layer alone owns these value objects.
201
+ */
202
+ function affectedNeighborhoodMetrics(task) {
203
+ const assessment = task.affectedNeighborhood;
204
+ const one = (field, label) => {
205
+ const unit = field === "taskOverlapPercent" ? "percent" : "count";
206
+ if (!assessment)
207
+ return unavailableMetric(unit, "derived", NO_AFFECTED_NEIGHBORHOOD_REASON);
208
+ const value = assessment[field];
209
+ if (typeof value === "number")
210
+ return availableMetric(value, unit, "derived");
211
+ const context = `assessment ${assessment.status}; seed mapping ${assessment.seedMappingStatus}; ` +
212
+ `graph ${assessment.graphEvidenceStatus}; neighborhood ${assessment.neighborhoodStatus}; task mapping ${assessment.taskMapping.status}` +
213
+ (field === "taskOverlapPercent" ? `; resolvable task nodes ${assessment.taskMapping.resolvableTaskNodeCount}` : "");
214
+ return unavailableMetric(unit, "derived", `Affected-neighborhood evidence did not establish ${label} (${context}).`);
215
+ };
216
+ return {
217
+ changedFileCount: one("changedFileCount", "the changed indexed file count"),
218
+ changedSymbolCount: one("changedSymbolCount", "the changed baseline symbol count"),
219
+ affectedNodeCount: one("affectedNodeCount", "the affected graph node count"),
220
+ affectedEdgeCount: one("affectedEdgeCount", "the affected graph edge count"),
221
+ taskOverlapCount: one("taskOverlapCount", "the task-overlap node count"),
222
+ taskOverlapPercent: one("taskOverlapPercent", "the task-overlap percent"),
223
+ };
224
+ }
195
225
  /** Pure, order-preserving calculation; ordinals and cumulative state restart for every project. */
196
226
  export function calculateWarmIndexMetrics(projects, agentEvidence) {
197
227
  if (agentEvidence && agentEvidence.length !== projects.length) {
@@ -222,8 +252,49 @@ export function toWarmOutcomeMetrics(task, variantId) {
222
252
  { id: "cumulative-context-estimated-token-count", name: "Cumulative estimated context tokens", description: "Sum of retrieved estimated context tokens for tasks 1..N in this project; not provider token usage.", metric: task.warm.cumulativeEstimatedContextTokens },
223
253
  { id: "amortized-index-build-duration-ms", name: "Amortized index build duration", description: "Index build duration divided by this task's ordinal within its project.", metric: task.warm.amortizedIndexBuildDurationMs },
224
254
  ...agentSpecs(task.warm),
255
+ ...affectedNeighborhoodSpecs(task.warm),
225
256
  ]);
226
257
  }
258
+ function affectedNeighborhoodSpecs(warm) {
259
+ return [
260
+ {
261
+ id: "affected-neighborhood-changed-file-count",
262
+ name: "Changed indexed files",
263
+ description: "Unique confirmed modified or missing files that the baseline index snapshot represents; new or non-indexed files are not counted.",
264
+ metric: warm.changedFileCount,
265
+ },
266
+ {
267
+ id: "affected-neighborhood-changed-symbol-count",
268
+ name: "Changed baseline symbols",
269
+ description: "Baseline indexed symbol identities (file path + name) belonging to confirmed changed indexed files; not proof that each symbol's own source text changed.",
270
+ metric: warm.changedSymbolCount,
271
+ },
272
+ {
273
+ id: "affected-neighborhood-node-count",
274
+ name: "Affected graph nodes",
275
+ description: "Unique resolved seed nodes plus their direct one-hop baseline graph neighbors, over edges of any kind and direction.",
276
+ metric: warm.affectedNodeCount,
277
+ },
278
+ {
279
+ id: "affected-neighborhood-edge-count",
280
+ name: "Affected graph edges",
281
+ description: "Unique baseline graph edges incident to at least one resolved seed node; not the induced subgraph of all affected nodes.",
282
+ metric: warm.affectedEdgeCount,
283
+ },
284
+ {
285
+ id: "affected-neighborhood-task-overlap-count",
286
+ name: "Task-overlap nodes",
287
+ description: "Unique baseline graph nodes that are both in the affected neighborhood and resolved from the task's expected files and symbols.",
288
+ metric: warm.taskOverlapCount,
289
+ },
290
+ {
291
+ id: "affected-neighborhood-task-overlap-percent",
292
+ name: "Task-overlap percent",
293
+ description: "Task-overlap nodes as a percentage of resolvable task graph nodes; unresolved or ambiguous expected metadata is not in the denominator.",
294
+ metric: warm.taskOverlapPercent,
295
+ },
296
+ ];
297
+ }
227
298
  function agentSpecs(side) {
228
299
  return [
229
300
  {
@@ -1,6 +1,8 @@
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 { loadAffectedNeighborhoodGraphEvidence } from "../../../evaluation/affectedNeighborhood.js";
5
+ import { captureIndexSnapshot } from "../../../evaluation/indexSnapshot.js";
4
6
  /**
5
7
  * Builds the index once through the shared `buildMyDevKitIndex` owner. A build that reports
6
8
  * success but leaves no index directory is not treated as a prepared session.
@@ -17,12 +19,33 @@ export async function prepareWarmIndexSession(options) {
17
19
  }
18
20
  return { ok: false, warnings: [...build.warnings, message], build };
19
21
  }
22
+ const sourceRoots = Object.freeze([...options.target.sourceRoots]);
23
+ // One `--version` probe per prepared session, outside the measured index build; it never fails
24
+ // the session (an unsupported probe becomes explicit `unavailable` tool evidence).
25
+ const tool = await probeMyDevKitVersion({ kitCommand: options.kitCommand, commandsDir: options.commandsDir });
26
+ // Reads the just-built index and the files it lists; never invokes my-dev-kit again and never
27
+ // fails the session (an uninterpretable index becomes an explicit `unavailable` snapshot).
28
+ const indexSnapshot = await captureIndexSnapshot({
29
+ indexDir: build.indexDir,
30
+ targetRoot: options.target.absoluteTargetRoot,
31
+ sourceRoots,
32
+ command: build.command,
33
+ tool
34
+ });
35
+ // Loaded once per session from the manifest-referenced artifacts; never fails the session.
36
+ const affectedNeighborhoodGraph = await loadAffectedNeighborhoodGraphEvidence({
37
+ indexDir: build.indexDir,
38
+ sourceRoots,
39
+ indexSnapshot
40
+ });
20
41
  const session = Object.freeze({
21
42
  indexDir: build.indexDir,
22
43
  buildDurationMs: build.durationMs,
23
44
  targetRoot: options.target.absoluteTargetRoot,
24
- sourceRoots: Object.freeze([...options.target.sourceRoots]),
25
- buildCommand: build.command
45
+ sourceRoots,
46
+ buildCommand: build.command,
47
+ indexSnapshot,
48
+ affectedNeighborhoodGraph
26
49
  });
27
50
  return { ok: true, session, build };
28
51
  }
@@ -97,6 +97,28 @@ 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
+ }
110
+ /** Neutral, count-only sentence derived from the report's persisted affected-neighborhood summary. */
111
+ function affectedNeighborhoodSentence(report) {
112
+ const summary = report.affectedNeighborhoodSummary;
113
+ if (!summary || summary.assessedTaskCount === 0) {
114
+ return "No per-task affected-neighborhood assessment was available in this report.";
115
+ }
116
+ return (`Affected-neighborhood evidence was available for ${summary.assessedTaskCount} task boundar${summary.assessedTaskCount === 1 ? "y" : "ies"}: ` +
117
+ `complete=${summary.completeAssessmentCount}, partial=${summary.partialAssessmentCount}, unavailable=${summary.unavailableAssessmentCount}; ` +
118
+ `relationships were related=${summary.relatedTaskCount}, unrelated=${summary.unrelatedTaskCount}, unknown=${summary.unknownRelationshipTaskCount}; ` +
119
+ `reindex recommendations were recommended=${summary.recommendedReindexCount}, not-indicated=${summary.notIndicatedReindexCount}, unknown=${summary.unknownReindexRecommendationCount}. ` +
120
+ "Affected-neighborhood evidence uses a one-hop baseline graph around confirmed changed indexed files/symbols. It is observational and does not alter retrieval or execution status.");
121
+ }
100
122
  function buildInterpretation(run, contextStrategyComparisonV043, warmIndexReuse) {
101
123
  if (run.pluginId === "warm-index-reuse" && warmIndexReuse) {
102
124
  const summary = warmIndexReuse.summary;
@@ -107,7 +129,8 @@ function buildInterpretation(run, contextStrategyComparisonV043, warmIndexReuse)
107
129
  "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
130
  `Deterministic fake-agent correctness/token evidence is available for ${summary.agentCorrectnessAvailableCount} of ${summary.agentSideCount} task sides (correctness) and ${summary.agentTotalTokensAvailableCount} (total tokens); ` +
109
131
  "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.",
132
+ "Estimated context-token values are context-size estimates, not provider token usage. " +
133
+ freshnessSentence(warmIndexReuse) + " " + affectedNeighborhoodSentence(warmIndexReuse),
111
134
  recommendedNextStep: run.status === "completed"
112
135
  ? "Review per-task measurements and cumulative component costs for each project; the variants are reported side by side and are not ranked."
113
136
  : "Inspect unavailable metrics, warnings, and failures before drawing conclusions from this run.",
@@ -132,7 +155,8 @@ function buildInterpretation(run, contextStrategyComparisonV043, warmIndexReuse)
132
155
  `Agent evidence status: ${campaign.agentEvidenceStatus} (${campaign.executedSideCount} of ${campaign.scheduledSideCount} scheduled sides executed` +
133
156
  `${nonCompletedCounts ? `; outcome counts: ${nonCompletedCounts}` : ""}). ` +
134
157
  `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.",
158
+ "Context estimated tokens remain a separate character-based context-size estimate and are never substituted for provider telemetry. " +
159
+ freshnessSentence(warmIndexReuse) + " " + affectedNeighborhoodSentence(warmIndexReuse),
136
160
  recommendedNextStep: isInfrastructureComplete && isAgentEvidenceComplete
137
161
  ? "Review per-task correctness, token provenance, and cumulative component measurements."
138
162
  : "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_AFFECTED_EDGES, MAX_REPORT_AFFECTED_NODES, MAX_REPORT_AFFECTED_TASK_AMBIGUOUS, MAX_REPORT_AFFECTED_TASK_UNRESOLVED, MAX_REPORT_AFFECTED_WARNINGS, 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,24 @@ 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
+ ];
33
+ const AFFECTED_NEIGHBORHOOD_LIMITATIONS = [
34
+ "Affected-neighborhood evidence uses a one-hop baseline graph around confirmed changed indexed files/symbols. It is observational and does not alter retrieval or execution status.",
35
+ "A changed baseline symbol means the symbol was present in a confirmed changed indexed file; it is not proof that the symbol's own source text changed.",
36
+ "Reindex recommendation categories describe this bounded analysis only. They do not claim that reindexing is mandatory, that it guarantees correctness, that skipping it is universally safe, or that the whole repository is fresh.",
37
+ ];
38
+ /** Fixed neutral wording per recommendation category. */
39
+ export const REINDEX_RECOMMENDATION_EXPLANATIONS = {
40
+ recommended: "Reindex recommended: the task has confirmed overlap with the affected one-hop baseline graph neighborhood.",
41
+ "not-indicated": "Reindex not indicated by this bounded analysis: complete evidence found no task-node overlap with the affected neighborhood.",
42
+ unknown: "Reindex recommendation unknown: evidence is incomplete or unavailable, so absence of observed overlap is not sufficient to conclude that the task is unaffected.",
43
+ };
26
44
  const COST_MODEL = [
27
45
  "Index construction is a one-time cost per benchmark project; task retrieval is the repeated warm cost.",
28
46
  "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 +108,8 @@ export function buildWarmIndexReuseReport(run) {
90
108
  warm: task.warm,
91
109
  rawAgent: toReportAgent(rawEvidence),
92
110
  warmAgent: toReportAgent(warmEvidence),
111
+ indexFreshness: toReportFreshness(execution.tasks[taskIndex].indexFreshness),
112
+ affectedNeighborhood: toReportAffectedNeighborhood(execution.tasks[taskIndex].affectedNeighborhood, task.warm),
93
113
  };
94
114
  }),
95
115
  };
@@ -121,6 +141,114 @@ export function buildWarmIndexReuseReport(run) {
121
141
  projects,
122
142
  agent: agentIdentity,
123
143
  agentCampaign,
144
+ indexFreshnessSummary: summarizeFreshness(projects.flatMap((project) => project.tasks)),
145
+ affectedNeighborhoodSummary: summarizeAffectedNeighborhood(projects.flatMap((project) => project.tasks)),
146
+ };
147
+ }
148
+ function boundedList(source, limit, map) {
149
+ const items = source.slice(0, limit).map(map);
150
+ return { totalCount: source.length, displayedCount: items.length, omittedCount: source.length - items.length, items };
151
+ }
152
+ /**
153
+ * Presents persisted freshness evidence only: it never recomputes a status, count, or hash and
154
+ * never touches the filesystem. Absent evidence stays null rather than becoming `unknown`.
155
+ */
156
+ function toReportFreshness(freshness) {
157
+ if (!freshness)
158
+ return null;
159
+ return {
160
+ status: freshness.status,
161
+ assessedAt: freshness.assessedAt,
162
+ baselineSnapshotStatus: freshness.baselineSnapshotStatus,
163
+ indexedFileCount: freshness.indexedFileCount,
164
+ comparableFileCount: freshness.comparableFileCount,
165
+ unchangedFileCount: freshness.unchangedFileCount,
166
+ changedFileCount: freshness.changedFileCount,
167
+ missingFileCount: freshness.missingFileCount,
168
+ unresolvedFileCount: freshness.unresolvedFileCount,
169
+ changes: boundedList(freshness.changes, MAX_REPORT_FRESHNESS_CHANGES, (change) => ({ path: change.path, changeType: change.changeType })),
170
+ unresolved: boundedList(freshness.unresolved, MAX_REPORT_FRESHNESS_UNRESOLVED, (entry) => ({
171
+ path: entry.path,
172
+ reasonCode: entry.reasonCode,
173
+ message: entry.message,
174
+ })),
175
+ warnings: [...freshness.warnings],
176
+ };
177
+ }
178
+ /** Bounded list whose totals come from persisted counts, which can exceed the persisted item list. */
179
+ function boundedListWithTotal(source, totalCount, limit, map) {
180
+ const items = source.slice(0, limit).map(map);
181
+ const total = Math.max(totalCount, source.length);
182
+ return { totalCount: total, displayedCount: items.length, omittedCount: total - items.length, items };
183
+ }
184
+ /**
185
+ * Presents a persisted affected-neighborhood assessment only: it never traverses, remaps seeds,
186
+ * recomputes overlap, or reads the graph, index, or filesystem. Numeric fields are the warm-side
187
+ * metric objects already calculated by the metric owner. Absent evidence stays null.
188
+ */
189
+ function toReportAffectedNeighborhood(assessment, warm) {
190
+ if (!assessment)
191
+ return null;
192
+ const mapping = assessment.taskMapping;
193
+ return {
194
+ status: assessment.status,
195
+ freshnessStatus: assessment.freshnessStatus,
196
+ seedMappingStatus: assessment.seedMappingStatus,
197
+ graphEvidenceStatus: assessment.graphEvidenceStatus,
198
+ neighborhoodStatus: assessment.neighborhoodStatus,
199
+ metrics: {
200
+ changedFileCount: warm.changedFileCount,
201
+ changedSymbolCount: warm.changedSymbolCount,
202
+ affectedNodeCount: warm.affectedNodeCount,
203
+ affectedEdgeCount: warm.affectedEdgeCount,
204
+ taskOverlapCount: warm.taskOverlapCount,
205
+ taskOverlapPercent: warm.taskOverlapPercent,
206
+ },
207
+ taskMappingStatus: mapping.status,
208
+ resolvableTaskNodeCount: mapping.resolvableTaskNodeCount,
209
+ relationship: assessment.relationship,
210
+ reindexRecommendation: assessment.reindexRecommendation,
211
+ recommendationExplanation: REINDEX_RECOMMENDATION_EXPLANATIONS[assessment.reindexRecommendation],
212
+ affectedNodeIds: boundedList(assessment.affectedNodeIds, MAX_REPORT_AFFECTED_NODES, (id) => id),
213
+ participatingEdgeIds: boundedList(assessment.participatingEdgeIds, MAX_REPORT_AFFECTED_EDGES, (id) => id),
214
+ unresolvedTaskMappings: boundedListWithTotal(mapping.unresolved, mapping.unresolvedCount, MAX_REPORT_AFFECTED_TASK_UNRESOLVED, (entry) => ({ ...entry })),
215
+ ambiguousTaskSymbols: boundedListWithTotal(mapping.ambiguousSymbols, mapping.ambiguousCount, MAX_REPORT_AFFECTED_TASK_AMBIGUOUS, (entry) => ({
216
+ name: entry.name,
217
+ candidateNodeIds: [...entry.candidateNodeIds],
218
+ })),
219
+ warnings: boundedListWithTotal(assessment.warnings, assessment.warningCount, MAX_REPORT_AFFECTED_WARNINGS, (warning) => ({
220
+ code: warning.code,
221
+ message: warning.message,
222
+ })),
223
+ };
224
+ }
225
+ function summarizeAffectedNeighborhood(tasks) {
226
+ const assessed = tasks.flatMap((task) => (task.affectedNeighborhood ? [task.affectedNeighborhood] : []));
227
+ const count = (key, value) => assessed.filter((entry) => entry[key] === value).length;
228
+ return {
229
+ assessedTaskCount: assessed.length,
230
+ unassessedTaskCount: tasks.length - assessed.length,
231
+ completeAssessmentCount: count("status", "complete"),
232
+ partialAssessmentCount: count("status", "partial"),
233
+ unavailableAssessmentCount: count("status", "unavailable"),
234
+ relatedTaskCount: count("relationship", "related"),
235
+ unrelatedTaskCount: count("relationship", "unrelated"),
236
+ unknownRelationshipTaskCount: count("relationship", "unknown"),
237
+ recommendedReindexCount: count("reindexRecommendation", "recommended"),
238
+ notIndicatedReindexCount: count("reindexRecommendation", "not-indicated"),
239
+ unknownReindexRecommendationCount: count("reindexRecommendation", "unknown"),
240
+ };
241
+ }
242
+ function summarizeFreshness(tasks) {
243
+ const assessed = tasks.flatMap((task) => (task.indexFreshness ? [task.indexFreshness] : []));
244
+ const count = (status) => assessed.filter((freshness) => freshness.status === status).length;
245
+ return {
246
+ assessedTaskCount: assessed.length,
247
+ unassessedTaskCount: tasks.length - assessed.length,
248
+ freshTaskCount: count("fresh"),
249
+ staleTaskCount: count("stale"),
250
+ partiallyStaleTaskCount: count("partially-stale"),
251
+ unknownTaskCount: count("unknown"),
124
252
  };
125
253
  }
126
254
  function deriveAgentIdentity(args) {
@@ -191,10 +319,10 @@ function buildCampaignSummary(args) {
191
319
  }
192
320
  function buildLimitations(agent) {
193
321
  if (agent.mode === "deterministic-fake") {
194
- return [...COMMON_LIMITATIONS, ...FAKE_AGENT_LIMITATIONS];
322
+ return [...COMMON_LIMITATIONS, ...FAKE_AGENT_LIMITATIONS, ...FRESHNESS_LIMITATIONS, ...AFFECTED_NEIGHBORHOOD_LIMITATIONS];
195
323
  }
196
324
  const providerLimitations = agent.id === "codex" ? CODEX_CAMPAIGN_LIMITATIONS : CLAUDE_CAMPAIGN_LIMITATIONS;
197
- return [...COMMON_LIMITATIONS, ...providerLimitations];
325
+ return [...COMMON_LIMITATIONS, ...providerLimitations, ...FRESHNESS_LIMITATIONS, ...AFFECTED_NEIGHBORHOOD_LIMITATIONS];
198
326
  }
199
327
  function toReportAgent(agent) {
200
328
  if (!agent)
@@ -301,6 +301,8 @@ 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);
305
+ renderAffectedNeighborhoodSummary(lines, section);
304
306
  if (section.agentCampaign) {
305
307
  renderCampaignSection(lines, section.agentCampaign);
306
308
  }
@@ -324,6 +326,8 @@ function renderWarmIndexReuseSection(lines, section) {
324
326
  const rows = [
325
327
  ["Raw Status", task.rawStatus],
326
328
  ["Warm Status", task.warmStatus],
329
+ ["Index Freshness Status", task.indexFreshness ? task.indexFreshness.status : "not assessed"],
330
+ ["Affected Neighborhood Status", task.affectedNeighborhood ? task.affectedNeighborhood.status : "not assessed"],
327
331
  ["Raw Context Characters", formatWarmMetric(task.raw.contextCharacters)],
328
332
  ["Warm Context Characters", formatWarmMetric(task.warm.contextCharacters)],
329
333
  ["Raw Estimated Context Tokens (estimate)", formatWarmMetric(task.raw.contextEstimatedTokens)],
@@ -347,9 +351,144 @@ function renderWarmIndexReuseSection(lines, section) {
347
351
  for (const [label, value] of rows) {
348
352
  lines.push(fieldLine(label, value));
349
353
  }
354
+ renderTaskFreshnessDetail(lines, task.indexFreshness);
355
+ renderTaskAffectedNeighborhoodDetail(lines, task.affectedNeighborhood);
350
356
  }
351
357
  });
352
358
  }
359
+ function renderFreshnessSummary(lines, section) {
360
+ // Reports serialized before v0.6.0 Batch 3 lack the summary; render them as having no assessment.
361
+ const summary = section.indexFreshnessSummary ?? {
362
+ assessedTaskCount: 0,
363
+ unassessedTaskCount: section.summary.taskCount,
364
+ freshTaskCount: 0,
365
+ staleTaskCount: 0,
366
+ partiallyStaleTaskCount: 0,
367
+ unknownTaskCount: 0,
368
+ };
369
+ pushSection(lines, "Index Freshness Summary");
370
+ lines.push(fieldLine("Assessed Tasks", summary.assessedTaskCount));
371
+ lines.push(fieldLine("Unassessed Tasks", summary.unassessedTaskCount));
372
+ lines.push(fieldLine("Fresh Tasks", summary.freshTaskCount));
373
+ lines.push(fieldLine("Stale Tasks", summary.staleTaskCount));
374
+ lines.push(fieldLine("Partially-Stale Tasks", summary.partiallyStaleTaskCount));
375
+ lines.push(fieldLine("Unknown Tasks", summary.unknownTaskCount));
376
+ }
377
+ function renderAffectedNeighborhoodSummary(lines, section) {
378
+ // Reports serialized before v0.6.1 lack the summary; render them as having no assessment.
379
+ const summary = section.affectedNeighborhoodSummary ?? {
380
+ assessedTaskCount: 0,
381
+ unassessedTaskCount: section.summary.taskCount,
382
+ completeAssessmentCount: 0,
383
+ partialAssessmentCount: 0,
384
+ unavailableAssessmentCount: 0,
385
+ relatedTaskCount: 0,
386
+ unrelatedTaskCount: 0,
387
+ unknownRelationshipTaskCount: 0,
388
+ recommendedReindexCount: 0,
389
+ notIndicatedReindexCount: 0,
390
+ unknownReindexRecommendationCount: 0,
391
+ };
392
+ pushSection(lines, "Affected Neighborhood Summary");
393
+ lines.push(fieldLine("Assessed Tasks", summary.assessedTaskCount));
394
+ lines.push(fieldLine("Unassessed Tasks", summary.unassessedTaskCount));
395
+ lines.push(fieldLine("Complete Assessments", summary.completeAssessmentCount));
396
+ lines.push(fieldLine("Partial Assessments", summary.partialAssessmentCount));
397
+ lines.push(fieldLine("Unavailable Assessments", summary.unavailableAssessmentCount));
398
+ lines.push(fieldLine("Related Tasks", summary.relatedTaskCount));
399
+ lines.push(fieldLine("Unrelated Tasks", summary.unrelatedTaskCount));
400
+ lines.push(fieldLine("Unknown-Relationship Tasks", summary.unknownRelationshipTaskCount));
401
+ lines.push(fieldLine("Reindex Recommended", summary.recommendedReindexCount));
402
+ lines.push(fieldLine("Reindex Not Indicated", summary.notIndicatedReindexCount));
403
+ lines.push(fieldLine("Reindex Recommendation Unknown", summary.unknownReindexRecommendationCount));
404
+ }
405
+ /** Renders the persisted affected-neighborhood evidence; numeric lines come from the metric owner. */
406
+ function renderTaskAffectedNeighborhoodDetail(lines, evidence) {
407
+ if (!evidence)
408
+ return;
409
+ lines.push(fieldLine("Affected Neighborhood Relationship", evidence.relationship));
410
+ lines.push(fieldLine("Reindex Recommendation", evidence.reindexRecommendation));
411
+ lines.push(fieldLine("Reindex Recommendation Explanation", evidence.recommendationExplanation));
412
+ lines.push(fieldLine("Affected Neighborhood Freshness Status", evidence.freshnessStatus));
413
+ lines.push(fieldLine("Seed Mapping Status", evidence.seedMappingStatus));
414
+ lines.push(fieldLine("Graph Evidence Status", evidence.graphEvidenceStatus));
415
+ lines.push(fieldLine("Neighborhood Status", evidence.neighborhoodStatus));
416
+ lines.push(fieldLine("Task Mapping Status", evidence.taskMappingStatus));
417
+ lines.push(fieldLine("Resolvable Task Nodes", evidence.resolvableTaskNodeCount));
418
+ lines.push(fieldLine("Changed Indexed Files", formatWarmMetric(evidence.metrics.changedFileCount)));
419
+ lines.push(fieldLine("Changed Baseline Symbols", formatWarmMetric(evidence.metrics.changedSymbolCount)));
420
+ lines.push(fieldLine("Affected Graph Nodes", formatWarmMetric(evidence.metrics.affectedNodeCount)));
421
+ lines.push(fieldLine("Affected Graph Edges", formatWarmMetric(evidence.metrics.affectedEdgeCount)));
422
+ lines.push(fieldLine("Task-Overlap Nodes", formatWarmMetric(evidence.metrics.taskOverlapCount)));
423
+ lines.push(fieldLine("Task-Overlap Percent", formatWarmMetric(evidence.metrics.taskOverlapPercent)));
424
+ lines.push("Affected Node IDs:");
425
+ pushFreshnessBoundedNote(lines, evidence.affectedNodeIds);
426
+ if (evidence.affectedNodeIds.items.length === 0)
427
+ lines.push("- none");
428
+ for (const id of evidence.affectedNodeIds.items)
429
+ lines.push(`- ${sanitizeScalar(id)}`);
430
+ lines.push("Participating Edge IDs:");
431
+ pushFreshnessBoundedNote(lines, evidence.participatingEdgeIds);
432
+ if (evidence.participatingEdgeIds.items.length === 0)
433
+ lines.push("- none");
434
+ for (const id of evidence.participatingEdgeIds.items)
435
+ lines.push(`- ${sanitizeScalar(id)}`);
436
+ lines.push("Unresolved Task Mappings:");
437
+ pushFreshnessBoundedNote(lines, evidence.unresolvedTaskMappings);
438
+ if (evidence.unresolvedTaskMappings.items.length === 0)
439
+ lines.push("- none");
440
+ for (const entry of evidence.unresolvedTaskMappings.items) {
441
+ lines.push(`- ${sanitizeScalar(entry.subject)} ${sanitizeScalar(entry.name)} [${sanitizeScalar(entry.reason)}]`);
442
+ }
443
+ lines.push("Ambiguous Task Symbols:");
444
+ pushFreshnessBoundedNote(lines, evidence.ambiguousTaskSymbols);
445
+ if (evidence.ambiguousTaskSymbols.items.length === 0)
446
+ lines.push("- none");
447
+ for (const entry of evidence.ambiguousTaskSymbols.items) {
448
+ lines.push(`- ${sanitizeScalar(entry.name)}: ${entry.candidateNodeIds.map((id) => sanitizeScalar(id)).join(", ")}`);
449
+ }
450
+ lines.push("Affected Neighborhood Warnings:");
451
+ pushFreshnessBoundedNote(lines, evidence.warnings);
452
+ if (evidence.warnings.items.length === 0)
453
+ lines.push("- none");
454
+ for (const warning of evidence.warnings.items) {
455
+ lines.push(`- [${sanitizeScalar(warning.code)}] ${sanitizeScalar(warning.message)}`);
456
+ }
457
+ }
458
+ function pushFreshnessBoundedNote(lines, list) {
459
+ if (list.omittedCount > 0) {
460
+ lines.push(`Displayed: ${list.displayedCount} of ${list.totalCount}`);
461
+ lines.push(`Omitted: ${list.omittedCount}`);
462
+ }
463
+ }
464
+ /** Renders the persisted freshness counts and bounded evidence; hashes are never printed. */
465
+ function renderTaskFreshnessDetail(lines, freshness) {
466
+ if (!freshness)
467
+ return;
468
+ lines.push(fieldLine("Baseline Snapshot Status", freshness.baselineSnapshotStatus));
469
+ lines.push(fieldLine("Indexed Files", freshness.indexedFileCount));
470
+ lines.push(fieldLine("Comparable Files", freshness.comparableFileCount));
471
+ lines.push(fieldLine("Unchanged Files", freshness.unchangedFileCount));
472
+ lines.push(fieldLine("Modified Files", freshness.changedFileCount));
473
+ lines.push(fieldLine("Missing Files", freshness.missingFileCount));
474
+ lines.push(fieldLine("Unresolved Comparisons", freshness.unresolvedFileCount));
475
+ lines.push("Freshness Changes:");
476
+ pushFreshnessBoundedNote(lines, freshness.changes);
477
+ if (freshness.changes.items.length === 0) {
478
+ lines.push("- none");
479
+ }
480
+ for (const change of freshness.changes.items) {
481
+ lines.push(`- ${sanitizeScalar(change.changeType)} ${sanitizeScalar(change.path)}`);
482
+ }
483
+ lines.push("Freshness Unresolved Evidence:");
484
+ pushFreshnessBoundedNote(lines, freshness.unresolved);
485
+ if (freshness.unresolved.items.length === 0) {
486
+ lines.push("- none");
487
+ }
488
+ for (const entry of freshness.unresolved.items) {
489
+ lines.push(`- ${entry.path === null ? "run-level" : sanitizeScalar(entry.path)} [${sanitizeScalar(entry.reasonCode)}] ${sanitizeScalar(entry.message)}`);
490
+ }
491
+ }
353
492
  export function renderPluginExperimentReportText(report) {
354
493
  const lines = [];
355
494
  pushSection(lines, "Plugin Experiment Report");