@dailephd/my-dev-kit-lab 0.6.0 → 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.
@@ -228,7 +228,7 @@ async function enumerateIndexArtifacts(indexDir) {
228
228
  artifacts.sort((left, right) => compareCodeUnits(left.path, right.path));
229
229
  return { artifacts, truncated };
230
230
  }
231
- async function readJson(filePath) {
231
+ export async function readJson(filePath) {
232
232
  let text;
233
233
  try {
234
234
  text = await readFile(filePath, "utf8");
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { resolveWithinRoot } from "../../../core/pathSafety.js";
3
+ import { assessAffectedNeighborhood, mapAffectedNeighborhoodSeeds, } from "../../../evaluation/affectedNeighborhood.js";
3
4
  import { assessIndexFreshness } from "../../../evaluation/indexFreshness.js";
4
5
  import { runMyDevKitRetrievalFromIndex } from "../../../evaluation/runMyDevKitRetrieval.js";
5
6
  import { runRawFullFileBaseline } from "../../../evaluation/runRawFullFileBaseline.js";
@@ -92,6 +93,7 @@ async function executeTask(args) {
92
93
  let warmRetrieval;
93
94
  let warmStatus = "failed";
94
95
  let indexFreshness;
96
+ let affectedNeighborhood = null;
95
97
  if (!args.session) {
96
98
  errors.push(args.setupFailure ?? { side: "warm", code: "warm-index-setup-failed", message: "No warm index session." });
97
99
  // No snapshot exists, so freshness is conservatively unknown rather than assumed.
@@ -115,6 +117,24 @@ async function executeTask(args) {
115
117
  targetRoot: args.session.targetRoot,
116
118
  sourceRoots: args.session.sourceRoots,
117
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
+ }
118
138
  try {
119
139
  // requireKit: false retains subordinate command evidence; a skipped result caused by a
120
140
  // failed command is classified as failed below rather than as an honest skip.
@@ -149,6 +169,7 @@ async function executeTask(args) {
149
169
  rawBaseline,
150
170
  warmRetrieval,
151
171
  indexFreshness,
172
+ affectedNeighborhood,
152
173
  warnings,
153
174
  errors,
154
175
  };
@@ -47,6 +47,7 @@ function summarizeTask(task) {
47
47
  }
48
48
  : null,
49
49
  indexFreshness: task.indexFreshness ? structuredClone(task.indexFreshness) : null,
50
+ affectedNeighborhood: task.affectedNeighborhood ? structuredClone(task.affectedNeighborhood) : null,
50
51
  warnings: [...task.warnings],
51
52
  errors: task.errors.map((error) => ({ ...error })),
52
53
  };
@@ -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,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { buildMyDevKitIndex, probeMyDevKitVersion } from "../../../evaluation/runMyDevKitRetrieval.js";
4
+ import { loadAffectedNeighborhoodGraphEvidence } from "../../../evaluation/affectedNeighborhood.js";
4
5
  import { captureIndexSnapshot } from "../../../evaluation/indexSnapshot.js";
5
6
  /**
6
7
  * Builds the index once through the shared `buildMyDevKitIndex` owner. A build that reports
@@ -31,13 +32,20 @@ export async function prepareWarmIndexSession(options) {
31
32
  command: build.command,
32
33
  tool
33
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
+ });
34
41
  const session = Object.freeze({
35
42
  indexDir: build.indexDir,
36
43
  buildDurationMs: build.durationMs,
37
44
  targetRoot: options.target.absoluteTargetRoot,
38
45
  sourceRoots,
39
46
  buildCommand: build.command,
40
- indexSnapshot
47
+ indexSnapshot,
48
+ affectedNeighborhoodGraph
41
49
  });
42
50
  return { ok: true, session, build };
43
51
  }
@@ -107,6 +107,18 @@ function freshnessSentence(report) {
107
107
  `fresh=${summary.freshTaskCount}, stale=${summary.staleTaskCount}, partially-stale=${summary.partiallyStaleTaskCount}, unknown=${summary.unknownTaskCount}. ` +
108
108
  "Freshness is observational evidence over files represented by the index snapshot and does not change retrieval or provider status.");
109
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
+ }
110
122
  function buildInterpretation(run, contextStrategyComparisonV043, warmIndexReuse) {
111
123
  if (run.pluginId === "warm-index-reuse" && warmIndexReuse) {
112
124
  const summary = warmIndexReuse.summary;
@@ -118,7 +130,7 @@ function buildInterpretation(run, contextStrategyComparisonV043, warmIndexReuse)
118
130
  `Deterministic fake-agent correctness/token evidence is available for ${summary.agentCorrectnessAvailableCount} of ${summary.agentSideCount} task sides (correctness) and ${summary.agentTotalTokensAvailableCount} (total tokens); ` +
119
131
  "fake-agent totals are simulated harness evidence, not real-model or provider measurements. " +
120
132
  "Estimated context-token values are context-size estimates, not provider token usage. " +
121
- freshnessSentence(warmIndexReuse),
133
+ freshnessSentence(warmIndexReuse) + " " + affectedNeighborhoodSentence(warmIndexReuse),
122
134
  recommendedNextStep: run.status === "completed"
123
135
  ? "Review per-task measurements and cumulative component costs for each project; the variants are reported side by side and are not ranked."
124
136
  : "Inspect unavailable metrics, warnings, and failures before drawing conclusions from this run.",
@@ -144,7 +156,7 @@ function buildInterpretation(run, contextStrategyComparisonV043, warmIndexReuse)
144
156
  `${nonCompletedCounts ? `; outcome counts: ${nonCompletedCounts}` : ""}). ` +
145
157
  `Token evidence status: ${campaign.tokenEvidenceStatus} (${summary.agentTotalTokensAvailableCount} of ${summary.agentSideCount} task sides have a total-token value). ` +
146
158
  "Context estimated tokens remain a separate character-based context-size estimate and are never substituted for provider telemetry. " +
147
- freshnessSentence(warmIndexReuse),
159
+ freshnessSentence(warmIndexReuse) + " " + affectedNeighborhoodSentence(warmIndexReuse),
148
160
  recommendedNextStep: isInfrastructureComplete && isAgentEvidenceComplete
149
161
  ? "Review per-task correctness, token provenance, and cumulative component measurements."
150
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 { MAX_REPORT_FRESHNESS_CHANGES, MAX_REPORT_FRESHNESS_UNRESOLVED, 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.",
@@ -30,6 +30,17 @@ const FRESHNESS_LIMITATIONS = [
30
30
  "Freshness is observational evidence. It does not trigger reindexing, suppress warm retrieval, alter execution status, or alter provider/agent outcome status.",
31
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
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
+ };
33
44
  const COST_MODEL = [
34
45
  "Index construction is a one-time cost per benchmark project; task retrieval is the repeated warm cost.",
35
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.",
@@ -98,6 +109,7 @@ export function buildWarmIndexReuseReport(run) {
98
109
  rawAgent: toReportAgent(rawEvidence),
99
110
  warmAgent: toReportAgent(warmEvidence),
100
111
  indexFreshness: toReportFreshness(execution.tasks[taskIndex].indexFreshness),
112
+ affectedNeighborhood: toReportAffectedNeighborhood(execution.tasks[taskIndex].affectedNeighborhood, task.warm),
101
113
  };
102
114
  }),
103
115
  };
@@ -130,6 +142,7 @@ export function buildWarmIndexReuseReport(run) {
130
142
  agent: agentIdentity,
131
143
  agentCampaign,
132
144
  indexFreshnessSummary: summarizeFreshness(projects.flatMap((project) => project.tasks)),
145
+ affectedNeighborhoodSummary: summarizeAffectedNeighborhood(projects.flatMap((project) => project.tasks)),
133
146
  };
134
147
  }
135
148
  function boundedList(source, limit, map) {
@@ -162,6 +175,70 @@ function toReportFreshness(freshness) {
162
175
  warnings: [...freshness.warnings],
163
176
  };
164
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
+ }
165
242
  function summarizeFreshness(tasks) {
166
243
  const assessed = tasks.flatMap((task) => (task.indexFreshness ? [task.indexFreshness] : []));
167
244
  const count = (status) => assessed.filter((freshness) => freshness.status === status).length;
@@ -242,10 +319,10 @@ function buildCampaignSummary(args) {
242
319
  }
243
320
  function buildLimitations(agent) {
244
321
  if (agent.mode === "deterministic-fake") {
245
- return [...COMMON_LIMITATIONS, ...FAKE_AGENT_LIMITATIONS, ...FRESHNESS_LIMITATIONS];
322
+ return [...COMMON_LIMITATIONS, ...FAKE_AGENT_LIMITATIONS, ...FRESHNESS_LIMITATIONS, ...AFFECTED_NEIGHBORHOOD_LIMITATIONS];
246
323
  }
247
324
  const providerLimitations = agent.id === "codex" ? CODEX_CAMPAIGN_LIMITATIONS : CLAUDE_CAMPAIGN_LIMITATIONS;
248
- return [...COMMON_LIMITATIONS, ...providerLimitations, ...FRESHNESS_LIMITATIONS];
325
+ return [...COMMON_LIMITATIONS, ...providerLimitations, ...FRESHNESS_LIMITATIONS, ...AFFECTED_NEIGHBORHOOD_LIMITATIONS];
249
326
  }
250
327
  function toReportAgent(agent) {
251
328
  if (!agent)
@@ -302,6 +302,7 @@ function renderWarmIndexReuseSection(lines, section) {
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
304
  renderFreshnessSummary(lines, section);
305
+ renderAffectedNeighborhoodSummary(lines, section);
305
306
  if (section.agentCampaign) {
306
307
  renderCampaignSection(lines, section.agentCampaign);
307
308
  }
@@ -326,6 +327,7 @@ function renderWarmIndexReuseSection(lines, section) {
326
327
  ["Raw Status", task.rawStatus],
327
328
  ["Warm Status", task.warmStatus],
328
329
  ["Index Freshness Status", task.indexFreshness ? task.indexFreshness.status : "not assessed"],
330
+ ["Affected Neighborhood Status", task.affectedNeighborhood ? task.affectedNeighborhood.status : "not assessed"],
329
331
  ["Raw Context Characters", formatWarmMetric(task.raw.contextCharacters)],
330
332
  ["Warm Context Characters", formatWarmMetric(task.warm.contextCharacters)],
331
333
  ["Raw Estimated Context Tokens (estimate)", formatWarmMetric(task.raw.contextEstimatedTokens)],
@@ -350,6 +352,7 @@ function renderWarmIndexReuseSection(lines, section) {
350
352
  lines.push(fieldLine(label, value));
351
353
  }
352
354
  renderTaskFreshnessDetail(lines, task.indexFreshness);
355
+ renderTaskAffectedNeighborhoodDetail(lines, task.affectedNeighborhood);
353
356
  }
354
357
  });
355
358
  }
@@ -371,6 +374,87 @@ function renderFreshnessSummary(lines, section) {
371
374
  lines.push(fieldLine("Partially-Stale Tasks", summary.partiallyStaleTaskCount));
372
375
  lines.push(fieldLine("Unknown Tasks", summary.unknownTaskCount));
373
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
+ }
374
458
  function pushFreshnessBoundedNote(lines, list) {
375
459
  if (list.omittedCount > 0) {
376
460
  lines.push(`Displayed: ${list.displayedCount} of ${list.totalCount}`);
@@ -5,6 +5,8 @@ const UNIT_LABELS = {
5
5
  "estimated-tokens": "est. tokens",
6
6
  tokens: "tokens",
7
7
  score: "score",
8
+ count: "",
9
+ percent: "%",
8
10
  };
9
11
  export function renderWarmIndexReuseHtml(section) {
10
12
  if (section === null) {
@@ -26,6 +28,7 @@ export function renderWarmIndexReuseHtml(section) {
26
28
  ["Agent token totals available", `${section.summary.agentTotalTokensAvailableCount} of ${section.summary.agentSideCount}`],
27
29
  ])}
28
30
  ${renderFreshnessSummary(section.indexFreshnessSummary ?? legacyFreshnessSummary(section.summary.taskCount))}
31
+ ${renderAffectedNeighborhoodSummary(section.affectedNeighborhoodSummary ?? legacyAffectedNeighborhoodSummary(section.summary.taskCount))}
29
32
  ${section.agentCampaign ? renderCampaignSection(section.agentCampaign) : ""}
30
33
  <h3>Cold Start And Warm Reuse</h3>
31
34
  ${list(section.costModel)}
@@ -45,6 +48,100 @@ function legacyFreshnessSummary(taskCount) {
45
48
  unknownTaskCount: 0,
46
49
  };
47
50
  }
51
+ // Reports serialized before v0.6.1 lack the summary; they are rendered as having no assessment.
52
+ function legacyAffectedNeighborhoodSummary(taskCount) {
53
+ return {
54
+ assessedTaskCount: 0,
55
+ unassessedTaskCount: taskCount,
56
+ completeAssessmentCount: 0,
57
+ partialAssessmentCount: 0,
58
+ unavailableAssessmentCount: 0,
59
+ relatedTaskCount: 0,
60
+ unrelatedTaskCount: 0,
61
+ unknownRelationshipTaskCount: 0,
62
+ recommendedReindexCount: 0,
63
+ notIndicatedReindexCount: 0,
64
+ unknownReindexRecommendationCount: 0,
65
+ };
66
+ }
67
+ function renderAffectedNeighborhoodSummary(summary) {
68
+ return `<h3>Affected Neighborhood</h3>
69
+ ${table(["Field", "Value"], [
70
+ ["Assessed tasks", String(summary.assessedTaskCount)],
71
+ ["Unassessed tasks", String(summary.unassessedTaskCount)],
72
+ ["Complete assessments", String(summary.completeAssessmentCount)],
73
+ ["Partial assessments", String(summary.partialAssessmentCount)],
74
+ ["Unavailable assessments", String(summary.unavailableAssessmentCount)],
75
+ ["Related tasks", String(summary.relatedTaskCount)],
76
+ ["Unrelated tasks", String(summary.unrelatedTaskCount)],
77
+ ["Unknown-relationship tasks", String(summary.unknownRelationshipTaskCount)],
78
+ ["Reindex recommended", String(summary.recommendedReindexCount)],
79
+ ["Reindex not indicated", String(summary.notIndicatedReindexCount)],
80
+ ["Reindex recommendation unknown", String(summary.unknownReindexRecommendationCount)],
81
+ ])}`;
82
+ }
83
+ function boundedNote(label, taskOrdinal, bounded) {
84
+ return bounded.omittedCount > 0 ? [`Task ${taskOrdinal}: ${bounded.omittedCount} of ${bounded.totalCount} ${label} omitted.`] : [];
85
+ }
86
+ /** Persisted affected-neighborhood evidence per task; numeric cells are the metric owner's objects. */
87
+ function renderProjectAffectedNeighborhood(tasks) {
88
+ const overview = table(["Task", "Assessment", "Relationship", "Reindex recommendation", "Freshness", "Seeds", "Neighborhood", "Task mapping", "Resolvable task nodes"], tasks.map((task) => {
89
+ const label = `${task.taskOrdinal}. ${task.caseId}`;
90
+ const evidence = task.affectedNeighborhood;
91
+ return evidence
92
+ ? [
93
+ label,
94
+ evidence.status,
95
+ evidence.relationship,
96
+ evidence.reindexRecommendation,
97
+ evidence.freshnessStatus,
98
+ evidence.seedMappingStatus,
99
+ evidence.neighborhoodStatus,
100
+ evidence.taskMappingStatus,
101
+ String(evidence.resolvableTaskNodeCount),
102
+ ]
103
+ : [label, "not assessed", "", "", "", "", "", "", ""];
104
+ }));
105
+ const assessed = tasks.filter((task) => task.affectedNeighborhood !== null);
106
+ const metricRows = assessed.map((task) => {
107
+ const metrics = task.affectedNeighborhood.metrics;
108
+ return [
109
+ `${task.taskOrdinal}. ${task.caseId}`,
110
+ withReason(metrics.changedFileCount),
111
+ withReason(metrics.changedSymbolCount),
112
+ withReason(metrics.affectedNodeCount),
113
+ withReason(metrics.affectedEdgeCount),
114
+ withReason(metrics.taskOverlapCount),
115
+ withReason(metrics.taskOverlapPercent),
116
+ ];
117
+ });
118
+ const explanations = assessed.map((task) => `Task ${task.taskOrdinal}: ${task.affectedNeighborhood.recommendationExplanation}`);
119
+ const detailRows = assessed.flatMap((task) => {
120
+ const evidence = task.affectedNeighborhood;
121
+ const label = `${task.taskOrdinal}. ${task.caseId}`;
122
+ return [
123
+ ...evidence.unresolvedTaskMappings.items.map((entry) => [label, `unresolved ${entry.subject}`, entry.name, entry.reason]),
124
+ ...evidence.ambiguousTaskSymbols.items.map((entry) => [label, "ambiguous expected-symbol", entry.name, entry.candidateNodeIds.join(", ")]),
125
+ ...evidence.warnings.items.map((warning) => [label, "warning", warning.code, warning.message]),
126
+ ];
127
+ });
128
+ const omitted = assessed.flatMap((task) => {
129
+ const evidence = task.affectedNeighborhood;
130
+ return [
131
+ ...boundedNote("affected node IDs", task.taskOrdinal, evidence.affectedNodeIds),
132
+ ...boundedNote("participating edge IDs", task.taskOrdinal, evidence.participatingEdgeIds),
133
+ ...boundedNote("unresolved task mappings", task.taskOrdinal, evidence.unresolvedTaskMappings),
134
+ ...boundedNote("ambiguous task symbols", task.taskOrdinal, evidence.ambiguousTaskSymbols),
135
+ ...boundedNote("warnings", task.taskOrdinal, evidence.warnings),
136
+ ];
137
+ });
138
+ return `<h4>Affected Neighborhood</h4>
139
+ ${overview}
140
+ ${metricRows.length === 0 ? "" : table(["Task", "Changed files", "Changed symbols", "Affected nodes", "Affected edges", "Task-overlap nodes", "Task-overlap percent"], metricRows)}
141
+ ${explanations.length === 0 ? "" : list(explanations)}
142
+ ${detailRows.length === 0 ? "" : table(["Task", "Evidence type", "Name", "Detail"], detailRows)}
143
+ ${omitted.length === 0 ? "" : list(omitted)}`;
144
+ }
48
145
  function renderFreshnessSummary(summary) {
49
146
  return `<h3>Index Freshness</h3>
50
147
  ${table(["Field", "Value"], [
@@ -184,6 +281,7 @@ function renderProject(project, index, agentId) {
184
281
  formatMetric(task.warm.cumulativeEstimatedContextTokens),
185
282
  ]))}
186
283
  ${renderProjectFreshness(project.tasks)}
284
+ ${renderProjectAffectedNeighborhood(project.tasks)}
187
285
  <p class="muted">Estimated tokens are character-based context-size estimates, not provider token usage.</p>
188
286
  <p class="muted">${agentColumnsNote(agentId)}</p>
189
287
  ${table([
@@ -2,3 +2,8 @@ export const WARM_INDEX_REUSE_REPORT_SCHEMA_VERSION = "my-dev-kit-lab-warm-index
2
2
  /** Report presentation limits; the full persisted lists stay in warm-index-execution.json. */
3
3
  export const MAX_REPORT_FRESHNESS_CHANGES = 20;
4
4
  export const MAX_REPORT_FRESHNESS_UNRESOLVED = 10;
5
+ export const MAX_REPORT_AFFECTED_NODES = 20;
6
+ export const MAX_REPORT_AFFECTED_EDGES = 20;
7
+ export const MAX_REPORT_AFFECTED_TASK_UNRESOLVED = 10;
8
+ export const MAX_REPORT_AFFECTED_TASK_AMBIGUOUS = 10;
9
+ export const MAX_REPORT_AFFECTED_WARNINGS = 10;