@dotdotgod/cli 0.2.3 → 0.2.4

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/README.md CHANGED
@@ -38,7 +38,7 @@ dotdotgod graph impact . --changed <path> --compact
38
38
  - `index` builds `.dotdotgod/manifest.json` and compact graph shards from maintained project files.
39
39
  - `load-snapshot` returns a bounded first-pass project map for AI agents, including files pinned through the `load.pinnedPaths`/`load.pinnedBodies` config so context such as code conventions stays visible on every load.
40
40
  - `resolve` and `expand` map explicit or high-signal prompt references to project files.
41
- - `graph impact` ranks likely related specs, tests, docs, commands, and source files for a changed path.
41
+ - `graph impact` ranks likely related specs, tests, docs, commands, and source files for one or more changed paths, with a combined ranking and per-file top five.
42
42
  - `traceability links` checks or repairs generated Markdown traceability-link sections.
43
43
  - `plan validate` validates active plan artifacts and simplified Plan Generator checkpoints before execution.
44
44
  - `plan stage create` creates an internal `.dotdotgod-plan/NN_STAGE_NAME.md` checkpoint for a simplified Plan Generator stage.
@@ -66,8 +66,8 @@ dotdotgod plan validate docs/plan/<task-slug>/README.md
66
66
  dotdotgod plan validate docs/plan/<task-slug>/README.md --stage 04
67
67
  dotdotgod plan stage create 02 docs/plan/<task-slug>/README.md
68
68
  dotdotgod graph impact . --changed <path>
69
- dotdotgod graph impact . --changed <path> --compact
70
- dotdotgod graph impact . --changed <path> --yml
69
+ dotdotgod graph impact . --changed <path> --changed <another-path> --compact
70
+ dotdotgod graph impact . --changed <path> --changed <another-path> --yml
71
71
  dotdotgod graph communities .
72
72
  ```
73
73
 
@@ -104,7 +104,7 @@ files:
104
104
  - packages/pi/extensions/plan-mode/index.ts (45; implemented_by, semantic_similarity)
105
105
  ```
106
106
 
107
- `graph impact` needs `--changed <path>` so ranking has a seed file. Use `--compact` for short text, `--yml` or `--yaml` for compact structured agent-facing output, and `--json` for machine-readable detail.
107
+ `graph impact` needs at least one `--changed <path>` and accepts repeated options. Multi-file output preserves input order, deduplicates repeated paths, returns a bounded combined ranking, and includes the top five non-seed results for each changed file. Use `--compact` for short text, `--yml` or `--yaml` for compact structured agent-facing output, and `--json` for machine-readable detail.
108
108
 
109
109
  The graph is built from maintained project files: Markdown links, README routes, headings, traceability blocks, package metadata, memory areas, imports, exports, commands, tests, and deterministic routing hints.
110
110
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/cli",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "CLI for dotdotgod validation, project memory snapshots, and graph indexing.",
5
5
  "type": "module",
6
6
  "license": "Elastic-2.0",
package/src/cli/usage.mjs CHANGED
@@ -81,13 +81,13 @@ Write mode:
81
81
  Checks or writes generated Markdown traceability link sections from canonical fenced json dotdotgod blocks.`;
82
82
  case 'graph':
83
83
  return `Usage:
84
- dotdotgod graph impact <root> --changed <path> [--compact|--json|--yml|--yaml]
84
+ dotdotgod graph impact <root> --changed <path> [--changed <path> ...] [--compact|--json|--yml|--yaml]
85
85
  dotdotgod graph communities <root> [--json]`;
86
86
  case 'graph impact':
87
87
  return `Usage:
88
- dotdotgod graph impact <root> --changed <path> [--compact|--json|--yml|--yaml]
88
+ dotdotgod graph impact <root> --changed <path> [--changed <path> ...] [--compact|--json|--yml|--yaml]
89
89
 
90
- Ranks nodes related to a changed file. <root> is the project root; --changed is a project-relative file path. Use --compact for a short text summary or --yml/--yaml for structured agent-facing output.`;
90
+ Ranks nodes related to one or more changed files. <root> is the project root; repeat --changed with project-relative file paths. Results include a combined ranking and the top five related nodes for each changed file. Use --compact for a short text summary or --yml/--yaml for structured agent-facing output.`;
91
91
  case 'graph communities':
92
92
  return `Usage:
93
93
  dotdotgod graph communities <root> [--json]`;
@@ -109,7 +109,7 @@ Ranks nodes related to a changed file. <root> is the project root; --changed is
109
109
  dotdotgod plan validate docs/plan/<task-slug>/README.md [--stage stage] [--json]
110
110
  dotdotgod plan stage create <stage> [docs/plan/<task-slug>/README.md] [--json]
111
111
  dotdotgod trello sync <root> [--dry-run]
112
- dotdotgod graph impact <root> --changed <path> [--compact|--json|--yml|--yaml]
112
+ dotdotgod graph impact <root> --changed <path> [--changed <path> ...] [--compact|--json|--yml|--yaml]
113
113
  dotdotgod graph communities <root> [--json]`;
114
114
  }
115
115
  }
@@ -4,16 +4,18 @@ import { graphSummary, readFreshIndex } from '../index/cache.mjs';
4
4
  import { buildCompactImpactReport, buildImpactReport } from '../impact/report.mjs';
5
5
  import { formatCompactImpactOutput, formatYmlGraphImpactError, formatYmlImpactOutput } from '../impact/format.mjs';
6
6
 
7
+ const MAX_CHANGED_FILES = 20;
8
+
7
9
  export function parseGraphOptions(argv) {
8
10
  const filtered = [];
9
- let changed;
11
+ const changed = [];
10
12
  let compact = false;
11
13
  let yml = false;
12
14
  for (let i = 0; i < argv.length; i += 1) {
13
15
  if (argv[i] === '--changed') {
14
16
  const next = argv[i + 1];
15
17
  if (next && !next.startsWith('-')) {
16
- changed = next;
18
+ changed.push(next);
17
19
  i += 1;
18
20
  }
19
21
  } else if (argv[i] === '--compact') compact = true;
@@ -21,13 +23,13 @@ export function parseGraphOptions(argv) {
21
23
  else filtered.push(argv[i]);
22
24
  }
23
25
  const options = parseCommon(filtered);
24
- options.changed = changed;
26
+ options.changed = [...new Set(changed)];
25
27
  options.compact = compact;
26
28
  options.yml = yml;
27
29
  return options;
28
30
  }
29
31
 
30
- export function runGraph(argv) {
32
+ export async function runGraph(argv) {
31
33
  const sub = argv[0];
32
34
  const isImpact = sub === 'impact';
33
35
  if (!['impact', 'communities'].includes(sub)) usage(sub ? `Unknown graph command: ${sub}` : 'Missing graph command.', 'graph');
@@ -39,7 +41,7 @@ export function runGraph(argv) {
39
41
  else usage(message, 'graph impact');
40
42
  process.exit(2);
41
43
  }
42
- if (isImpact && !options.changed) {
44
+ if (isImpact && options.changed.length === 0) {
43
45
  const message = 'Missing required option: --changed <path>. Run `dotdotgod graph impact <root> --changed <path>`.';
44
46
  if (options.json) {
45
47
  console.log(JSON.stringify({ ok: false, command: 'graph impact', compact: options.compact || undefined, root: options.root, error: { code: 'MISSING_CHANGED', message }, usage: commandUsage('graph impact') }, null, 2));
@@ -51,11 +53,23 @@ export function runGraph(argv) {
51
53
  }
52
54
  usage(message, 'graph impact');
53
55
  }
56
+ if (isImpact && options.changed.length > MAX_CHANGED_FILES) {
57
+ const message = `Too many changed files: received ${options.changed.length}; maximum is ${MAX_CHANGED_FILES}.`;
58
+ if (options.json) {
59
+ console.log(JSON.stringify({ ok: false, command: 'graph impact', root: options.root, error: { code: 'TOO_MANY_CHANGED', message }, usage: commandUsage('graph impact') }, null, 2));
60
+ process.exit(2);
61
+ }
62
+ if (options.yml) {
63
+ console.log(formatYmlGraphImpactError(options, message, 'TOO_MANY_CHANGED'));
64
+ process.exit(2);
65
+ }
66
+ usage(message, 'graph impact');
67
+ }
54
68
  const { status, index, metadata } = readFreshIndex(options.root);
55
69
  const rawImpact = isImpact ? buildImpactReport(index, options.changed) : undefined;
56
70
  const impact = isImpact && (options.compact || options.yml) ? buildCompactImpactReport(rawImpact) : rawImpact;
57
71
  const payload = isImpact
58
- ? { ok: status.ok, command: 'graph impact', compact: options.compact || undefined, root: options.root, status, metadata, changed: options.changed, related: impact.related, impact }
72
+ ? { ok: status.ok, command: 'graph impact', compact: options.compact || undefined, root: options.root, status, metadata, changed: options.changed[0], changedFiles: options.changed, related: impact.related, impact }
59
73
  : { ok: status.ok, command: 'graph communities', root: options.root, status, metadata, graph: graphSummary(index), communities: buildCommunities(index) };
60
74
  const refreshNote = metadata.cacheRefreshed ? ', refreshed' : '';
61
75
  if (options.json) console.log(JSON.stringify(payload, null, 2));
package/src/core.mjs CHANGED
@@ -57,7 +57,7 @@ export async function runCli(argv = process.argv.slice(2)) {
57
57
  else if (command === 'resolve') runResolve(args);
58
58
  else if (command === 'expand') runExpand(args);
59
59
  else if (command === 'traceability') runTraceability(args);
60
- else if (command === 'graph') runGraph(args);
60
+ else if (command === 'graph') await runGraph(args);
61
61
  else if (command === 'plan') runPlan(args);
62
62
  else if (command === 'trello') {
63
63
  const result = await Promise.resolve(runTrelloSync(args));
@@ -15,7 +15,8 @@ function formatCompactImpactGroup(name, group) {
15
15
 
16
16
  export function formatCompactImpactOutput(payload, impact) {
17
17
  const refreshNote = payload.metadata.cacheRefreshed ? ', refreshed' : '';
18
- const lines = [`graph impact compact: ${impact.related.length} related node(s), ${impact.omittedRelated ?? 0} omitted (${payload.status.status}${refreshNote} index)`];
18
+ const lines = [`graph impact compact: ${impact.related.length} related node(s), ${impact.omittedRelated ?? 0} omitted (${payload.status.status}${refreshNote} index)`, `changed files: ${(impact.changedFiles ?? [impact.changed]).join(', ')}`];
19
+ for (const entry of impact.perSeed ?? []) lines.push(...formatCompactImpactGroup(`top for ${entry.changed}`, { items: entry.related }));
19
20
  for (const name of ['docs', 'contracts', 'tests', 'files', 'commands', 'events', 'packageResources', 'symbols']) lines.push(...formatCompactImpactGroup(name, impact.groups[name]));
20
21
  return lines.join('\n');
21
22
  }
@@ -58,6 +59,7 @@ export function formatYmlImpactOutput(payload, impact) {
58
59
  'impact:',
59
60
  ` ok: ${payload.ok ? 'true' : 'false'}`,
60
61
  ` changed: ${ymlScalar(payload.changed)}`,
62
+ ` changed_files: ${ymlList(payload.changedFiles ?? [payload.changed])}`,
61
63
  ` root: ${ymlScalar(payload.root)}`,
62
64
  ' output: "yml"',
63
65
  ' status:',
@@ -69,8 +71,16 @@ export function formatYmlImpactOutput(payload, impact) {
69
71
  ` method: ${ymlScalar(impact.ranking?.method)}`,
70
72
  ` preset: ${ymlScalar(impact.ranking?.preset)}`,
71
73
  ` config_source: ${ymlScalar(impact.ranking?.configSource)}`,
72
- ' groups:',
74
+ ' per_seed:',
73
75
  ];
76
+ for (const entry of impact.perSeed ?? []) {
77
+ lines.push(` - changed: ${ymlScalar(entry.changed)}`);
78
+ lines.push(` omitted_related: ${entry.omittedRelated ?? 0}`);
79
+ lines.push(' related:');
80
+ if ((entry.related ?? []).length === 0) lines.push(' []');
81
+ else for (const item of entry.related) lines.push(...formatYmlImpactItem(item));
82
+ }
83
+ lines.push(' groups:');
74
84
  for (const name of ['docs', 'contracts', 'tests', 'files', 'commands', 'events', 'packageResources', 'symbols']) lines.push(...formatYmlImpactGroup(name, impact.groups[name]));
75
85
  lines.push(' recommended_actions:');
76
86
  lines.push(' - "review_related_docs"');
@@ -11,7 +11,13 @@ function addImpactItem(group, item, limit = 10) {
11
11
  group.items.push(item);
12
12
  }
13
13
 
14
- function selectImpactItems(sortedItems, maxRelated, seed) {
14
+ function normalizeChangedPaths(changedPaths) {
15
+ const values = Array.isArray(changedPaths) ? changedPaths : [changedPaths];
16
+ return [...new Set(values.filter((value) => typeof value === 'string' && value.length > 0))];
17
+ }
18
+
19
+ function selectImpactItems(sortedItems, maxRelated, seeds) {
20
+ const seedSet = new Set(seeds);
15
21
  const selected = [];
16
22
  const selectedIds = new Set();
17
23
  const deferred = [];
@@ -22,35 +28,36 @@ function selectImpactItems(sortedItems, maxRelated, seed) {
22
28
  const add = (item, force = false) => {
23
29
  if (selected.length >= maxRelated || selectedIds.has(item.id)) return;
24
30
  const pathKey = item.path ?? item.id;
25
- if (!force && item.id !== seed && selected.length < firstPageLimit) {
31
+ if (!force && !seedSet.has(item.id) && selected.length < firstPageLimit) {
26
32
  if (pathKey && (firstPagePathCounts.get(pathKey) ?? 0) >= 2) return deferred.push(item);
27
33
  if (isLowActionabilityImpactItem(item) && lowActionabilityInFirstPage >= 2) return deferred.push(item);
28
34
  if (isSemanticOnlyImpactItem(item) && semanticOnlyInFirstPage >= 3) return deferred.push(item);
29
35
  }
30
36
  selected.push(item);
31
37
  selectedIds.add(item.id);
32
- if (selected.length <= firstPageLimit && item.id !== seed) {
38
+ if (selected.length <= firstPageLimit && !seedSet.has(item.id)) {
33
39
  if (pathKey) firstPagePathCounts.set(pathKey, (firstPagePathCounts.get(pathKey) ?? 0) + 1);
34
40
  if (isLowActionabilityImpactItem(item)) lowActionabilityInFirstPage += 1;
35
41
  if (isSemanticOnlyImpactItem(item)) semanticOnlyInFirstPage += 1;
36
42
  }
37
43
  };
38
- for (const item of sortedItems) if (item.id === seed) add(item, true);
39
- for (const item of sortedItems) if (item.id !== seed) add(item);
44
+ for (const item of sortedItems) if (seedSet.has(item.id)) add(item, true);
45
+ for (const item of sortedItems) if (!seedSet.has(item.id)) add(item);
40
46
  for (const item of deferred) add(item, true);
41
47
  return selected;
42
48
  }
43
49
 
44
- export function buildImpactReport(index, changedPath, limits = {}) {
50
+ function buildCombinedImpactReport(index, changedPaths, limits = {}) {
45
51
  const graph = index?.graph ?? { nodes: [], edges: [] };
46
52
  const config = index?.memoryConfig ? { ...defaultMemoryConfig(), ...index.memoryConfig } : defaultMemoryConfig();
47
53
  const policy = cloneImpactRankingPolicy(config.impactRanking ?? DEFAULT_IMPACT_RANKING_POLICY);
48
54
  const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
49
- const seed = `file:${changedPath}`;
50
- const maxRelated = limits.related ?? 25;
55
+ const seeds = changedPaths.map((path) => `file:${path}`);
56
+ const seedSet = new Set(seeds);
57
+ const maxRelated = Math.max(limits.related ?? 25, seeds.length);
51
58
  const groups = { files: { items: [], omitted: 0 }, docs: { items: [], omitted: 0 }, contracts: { items: [], omitted: 0 }, tests: { items: [], omitted: 0 }, commands: { items: [], omitted: 0 }, events: { items: [], omitted: 0 }, packageResources: { items: [], omitted: 0 }, symbols: { items: [], omitted: 0 } };
52
- const relatedIds = new Set([seed]);
53
- const reasons = new Map([[seed, new Set(['changed-file'])]]);
59
+ const relatedIds = new Set(seeds);
60
+ const reasons = new Map(seeds.map((seed) => [seed, new Set(['changed-file'])]));
54
61
  const addReason = (id, reason) => {
55
62
  relatedIds.add(id);
56
63
  if (!reasons.has(id)) reasons.set(id, new Set());
@@ -58,27 +65,27 @@ export function buildImpactReport(index, changedPath, limits = {}) {
58
65
  };
59
66
 
60
67
  for (const edge of graph.edges) {
61
- if (edge.source === seed) addReason(edge.target, edge.relation);
62
- if (edge.target === seed) addReason(edge.source, `incoming:${edge.relation}`);
68
+ if (seedSet.has(edge.source)) addReason(edge.target, edge.relation);
69
+ if (seedSet.has(edge.target)) addReason(edge.source, `incoming:${edge.relation}`);
63
70
  }
64
71
  const expansionRelations = new Set(['implemented_by', 'verified_by', 'related_doc', 'verification_command', ...SEMANTIC_RELATIONS]);
65
72
  for (const edge of graph.edges) {
66
73
  if (!expansionRelations.has(edge.relation)) continue;
67
- if (relatedIds.has(edge.source) && edge.target !== seed) addReason(edge.target, edge.relation);
74
+ if (relatedIds.has(edge.source) && !seedSet.has(edge.target)) addReason(edge.target, edge.relation);
68
75
  }
69
76
 
70
- const pprScores = buildPersonalizedPageRank(graph, seed, policy);
71
- const candidatePprMax = Math.max(0, ...[...relatedIds].filter((id) => id !== seed).map((id) => pprScores.get(id) ?? 0));
77
+ const pprScores = buildPersonalizedPageRank(graph, seeds, policy);
78
+ const candidatePprMax = Math.max(0, ...[...relatedIds].filter((id) => !seedSet.has(id)).map((id) => pprScores.get(id) ?? 0));
72
79
  const relatedAll = [...relatedIds].map((id) => {
73
80
  const node = nodeById.get(id) ?? { id };
74
81
  const path = node.path ?? id.replace(/^file:/, '').replace(/^test:/, '');
75
82
  const reasonList = [...(reasons.get(id) ?? [])];
76
83
  const retrieval = node.retrieval ?? retrievalMetadataForPath(path);
77
84
  const reasonSignals = reasonList.map((reason) => `reason:${reason}`);
78
- const scored = scoreImpactItem({ ...node, reasons: reasonList, retrieval }, seed, changedPath, policy, pprScores, candidatePprMax);
85
+ const scored = scoreImpactItem({ ...node, reasons: reasonList, retrieval }, seedSet, changedPaths, policy, pprScores, candidatePprMax);
79
86
  return { ...node, reasons: reasonList, retrieval: { ...retrieval, signals: [...new Set([...(retrieval.signals ?? []), ...reasonSignals])] }, ...scored };
80
- }).sort(compareImpactItems(seed));
81
- const related = selectImpactItems(relatedAll, maxRelated, seed);
87
+ }).sort(compareImpactItems(seeds));
88
+ const related = selectImpactItems(relatedAll, maxRelated, seeds);
82
89
  for (const item of related) {
83
90
  if (item.type === 'file') {
84
91
  const area = docsArea(item.path);
@@ -91,7 +98,19 @@ export function buildImpactReport(index, changedPath, limits = {}) {
91
98
  else if (item.type === 'event') addImpactItem(groups.events, item, limits.events ?? 10);
92
99
  else if (item.type === 'package_resource') addImpactItem(groups.packageResources, item, limits.packageResources ?? 10);
93
100
  }
94
- return { changed: changedPath, ranking: { method: policy.ppr.enabled === false ? 'policy-score' : 'personalized-pagerank+policy', preset: policy.preset, configSource: index?.memoryConfig?.source ?? 'default', weights: policy.weights, ppr: policy.ppr }, related, groups, omittedRelated: Math.max(0, relatedAll.length - related.length) };
101
+ return { changed: changedPaths[0], changedFiles: changedPaths, ranking: { method: policy.ppr.enabled === false ? 'policy-score' : 'personalized-pagerank+policy', preset: policy.preset, configSource: index?.memoryConfig?.source ?? 'default', weights: policy.weights, ppr: policy.ppr }, related, groups, omittedRelated: Math.max(0, relatedAll.length - related.length) };
102
+ }
103
+
104
+ export function buildImpactReport(index, changedPaths, limits = {}) {
105
+ const normalized = normalizeChangedPaths(changedPaths);
106
+ const aggregate = buildCombinedImpactReport(index, normalized, limits);
107
+ const perSeedLimit = limits.perSeed ?? 5;
108
+ aggregate.perSeed = normalized.map((changed) => {
109
+ const report = buildCombinedImpactReport(index, [changed], { ...limits, related: Math.max(limits.related ?? 25, perSeedLimit + 1) });
110
+ const related = report.related.filter((item) => item.id !== `file:${changed}`).slice(0, perSeedLimit);
111
+ return { changed, related, omittedRelated: Math.max(0, report.related.length - 1 - related.length) + report.omittedRelated };
112
+ });
113
+ return aggregate;
95
114
  }
96
115
 
97
116
  function compactScoreBreakdown(scoreBreakdown = {}) {
@@ -113,11 +132,14 @@ function compactImpactGroup(group = { items: [], omitted: 0 }, limit = 5) {
113
132
  }
114
133
 
115
134
  export function buildCompactImpactReport(impact, limits = {}) {
116
- const relatedLimit = limits.related ?? 10;
135
+ const relatedLimit = Math.max(limits.related ?? 10, impact.changedFiles?.length ?? 1);
117
136
  const groupLimit = limits.groupItems ?? 5;
137
+ const changedFiles = impact.changedFiles ?? [impact.changed];
138
+ const seedIds = new Set(changedFiles.map((path) => `file:${path}`));
118
139
  const related = (impact.related ?? []).slice(0, relatedLimit).map(compactImpactItem);
119
140
  const groupNames = ['files', 'docs', 'contracts', 'tests', 'commands', 'events', 'packageResources', 'symbols'];
120
141
  const groups = Object.fromEntries(groupNames.map((name) => [name, compactImpactGroup(impact.groups?.[name], groupLimit)]));
121
- const top10 = (impact.related ?? []).filter((item) => item.id !== `file:${impact.changed}`).slice(0, 10);
122
- return { changed: impact.changed, compact: true, ranking: { method: impact.ranking?.method, preset: impact.ranking?.preset, configSource: impact.ranking?.configSource }, related, groups, omittedRelated: (impact.omittedRelated ?? 0) + Math.max(0, (impact.related?.length ?? 0) - related.length), quality: { rawRelated: impact.related?.length ?? 0, compactRelated: related.length, semanticOnlyTop10: top10.filter((item) => isSemanticOnlyImpactItem(item)).length, curatedTop10: top10.filter((item) => hasCuratedImpactReason(item)).length, lowActionabilityTop10: top10.filter((item) => isLowActionabilityImpactItem(item)).length } };
142
+ const perSeed = (impact.perSeed ?? []).map((entry) => ({ changed: entry.changed, related: (entry.related ?? []).slice(0, limits.perSeed ?? 5).map(compactImpactItem), omittedRelated: entry.omittedRelated ?? 0 }));
143
+ const top10 = (impact.related ?? []).filter((item) => !seedIds.has(item.id)).slice(0, 10);
144
+ return { changed: impact.changed, changedFiles, perSeed, compact: true, ranking: { method: impact.ranking?.method, preset: impact.ranking?.preset, configSource: impact.ranking?.configSource }, related, groups, omittedRelated: (impact.omittedRelated ?? 0) + Math.max(0, (impact.related?.length ?? 0) - related.length), quality: { rawRelated: impact.related?.length ?? 0, compactRelated: related.length, semanticOnlyTop10: top10.filter((item) => isSemanticOnlyImpactItem(item)).length, curatedTop10: top10.filter((item) => hasCuratedImpactReason(item)).length, lowActionabilityTop10: top10.filter((item) => isLowActionabilityImpactItem(item)).length } };
123
145
  }
@@ -33,13 +33,16 @@ function cappedTraceabilityScore(reasons, boosts, cap) {
33
33
  return clamp(Math.max(...values) + Math.min(5, Math.max(0, values.length - 1) * 2), 0, cap);
34
34
  }
35
35
 
36
- export function buildPersonalizedPageRank(graph, seed, policy) {
36
+ export function buildPersonalizedPageRank(graph, seeds, policy) {
37
37
  if (policy.ppr.enabled === false) return new Map();
38
+ const seedList = Array.isArray(seeds) ? [...new Set(seeds)] : [seeds];
39
+ const seedSet = new Set(seedList);
40
+ const restart = seedList.length > 0 ? 1 / seedList.length : 0;
38
41
  const damping = policy.ppr.damping ?? 0.85;
39
42
  const iterations = policy.ppr.iterations ?? 20;
40
43
  const tolerance = policy.ppr.tolerance ?? 0.000001;
41
44
  const ids = new Set(graph.nodes.map((node) => node.id));
42
- ids.add(seed);
45
+ for (const seed of seedList) ids.add(seed);
43
46
  const adjacency = new Map([...ids].map((id) => [id, []]));
44
47
  for (const edge of graph.edges) {
45
48
  const weight = policy.relationWeights[edge.relation] ?? relationWeight(edge.relation);
@@ -49,9 +52,9 @@ export function buildPersonalizedPageRank(graph, seed, policy) {
49
52
  adjacency.get(edge.source).push([edge.target, weight]);
50
53
  adjacency.get(edge.target).push([edge.source, weight]);
51
54
  }
52
- let ranks = new Map([...adjacency.keys()].map((id) => [id, id === seed ? 1 : 0]));
55
+ let ranks = new Map([...adjacency.keys()].map((id) => [id, seedSet.has(id) ? restart : 0]));
53
56
  for (let i = 0; i < iterations; i += 1) {
54
- const next = new Map([...adjacency.keys()].map((id) => [id, id === seed ? 1 - damping : 0]));
57
+ const next = new Map([...adjacency.keys()].map((id) => [id, seedSet.has(id) ? (1 - damping) * restart : 0]));
55
58
  for (const [id, edges] of adjacency.entries()) {
56
59
  const rank = ranks.get(id) ?? 0;
57
60
  const total = edges.reduce((sum, [, weight]) => sum + weight, 0);
@@ -65,8 +68,10 @@ export function buildPersonalizedPageRank(graph, seed, policy) {
65
68
  return ranks;
66
69
  }
67
70
 
68
- export function scoreImpactItem(item, seed, changedPath, policy, pprScores, maxPpr) {
69
- if (item.id === seed) return { impactScore: 100, scoreBreakdown: { seed: 100, ppr: 40, traceability: 0, memoryPolicy: 0, verification: 0, proximity: 0, semantic: 0, freshness: 0, archivePenalty: 0 } };
71
+ export function scoreImpactItem(item, seeds, changedPaths, policy, pprScores, maxPpr) {
72
+ const seedSet = seeds instanceof Set ? seeds : new Set(Array.isArray(seeds) ? seeds : [seeds]);
73
+ const paths = Array.isArray(changedPaths) ? changedPaths : [changedPaths];
74
+ if (seedSet.has(item.id)) return { impactScore: 100, scoreBreakdown: { seed: 100, ppr: 40, traceability: 0, memoryPolicy: 0, verification: 0, proximity: 0, semantic: 0, freshness: 0, archivePenalty: 0 } };
70
75
  const reasons = item.reasons ?? [];
71
76
  const retrieval = item.retrieval ?? {};
72
77
  const pprNormalized = maxPpr > 0 ? (pprScores.get(item.id) ?? 0) / maxPpr : 0;
@@ -78,7 +83,7 @@ export function scoreImpactItem(item, seed, changedPath, policy, pprScores, maxP
78
83
  const semantic = clamp(sumReasonBoosts(reasons, policy.semanticBoosts), 0, Math.abs(policy.weights.semantic ?? 10));
79
84
  const freshness = retrieval.freshness === 'fresh' ? Math.abs(policy.weights.freshness ?? 5) : retrieval.freshness === 'stale' ? -Math.abs(policy.weights.freshness ?? 5) : 0;
80
85
  let archivePenalty = 0;
81
- if (!changedPath.startsWith('docs/archive/')) {
86
+ if (!paths.some((path) => path.startsWith('docs/archive/'))) {
82
87
  if (retrieval.area === 'archive-body') archivePenalty -= Math.abs(policy.weights.archivePenalty ?? -25);
83
88
  if (retrieval.includeBodiesByDefault === false) archivePenalty -= 10;
84
89
  if (retrieval.freshness === 'stale' && retrieval.area !== 'archive-map') archivePenalty -= 5;
@@ -125,10 +130,17 @@ function impactSelectionScore(item) {
125
130
  return score;
126
131
  }
127
132
 
128
- export function compareImpactItems(seed) {
133
+ export function compareImpactItems(seeds) {
134
+ const seedList = Array.isArray(seeds) ? seeds : [seeds];
135
+ const seedOrder = new Map(seedList.map((seed, index) => [seed, index]));
129
136
  return (a, b) => {
130
- if (a.id === seed) return -1;
131
- if (b.id === seed) return 1;
137
+ const aSeed = seedOrder.get(a.id);
138
+ const bSeed = seedOrder.get(b.id);
139
+ if (aSeed !== undefined || bSeed !== undefined) {
140
+ if (aSeed === undefined) return 1;
141
+ if (bSeed === undefined) return -1;
142
+ return aSeed - bSeed;
143
+ }
132
144
  return impactSelectionScore(b) - impactSelectionScore(a) || (b.impactScore ?? 0) - (a.impactScore ?? 0) || a.id.localeCompare(b.id);
133
145
  };
134
146
  }