cap-pro 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/README.md +26 -0
- package/.claude-plugin/marketplace.json +24 -0
- package/.claude-plugin/plugin.json +24 -0
- package/LICENSE +21 -0
- package/README.ja-JP.md +834 -0
- package/README.ko-KR.md +823 -0
- package/README.md +806 -0
- package/README.pt-BR.md +452 -0
- package/README.zh-CN.md +800 -0
- package/agents/cap-architect.md +269 -0
- package/agents/cap-brainstormer.md +207 -0
- package/agents/cap-curator.md +276 -0
- package/agents/cap-debugger.md +365 -0
- package/agents/cap-designer.md +246 -0
- package/agents/cap-historian.md +464 -0
- package/agents/cap-migrator.md +291 -0
- package/agents/cap-prototyper.md +197 -0
- package/agents/cap-validator.md +308 -0
- package/bin/install.js +5433 -0
- package/cap/bin/cap-tools.cjs +853 -0
- package/cap/bin/lib/arc-scanner.cjs +344 -0
- package/cap/bin/lib/cap-affinity-engine.cjs +862 -0
- package/cap/bin/lib/cap-anchor.cjs +228 -0
- package/cap/bin/lib/cap-annotation-writer.cjs +340 -0
- package/cap/bin/lib/cap-checkpoint.cjs +434 -0
- package/cap/bin/lib/cap-cluster-detect.cjs +945 -0
- package/cap/bin/lib/cap-cluster-display.cjs +52 -0
- package/cap/bin/lib/cap-cluster-format.cjs +245 -0
- package/cap/bin/lib/cap-cluster-helpers.cjs +295 -0
- package/cap/bin/lib/cap-cluster-io.cjs +212 -0
- package/cap/bin/lib/cap-completeness.cjs +540 -0
- package/cap/bin/lib/cap-deps.cjs +583 -0
- package/cap/bin/lib/cap-design-families.cjs +332 -0
- package/cap/bin/lib/cap-design.cjs +966 -0
- package/cap/bin/lib/cap-divergence-detector.cjs +400 -0
- package/cap/bin/lib/cap-doctor.cjs +752 -0
- package/cap/bin/lib/cap-feature-map-internals.cjs +19 -0
- package/cap/bin/lib/cap-feature-map-migrate.cjs +335 -0
- package/cap/bin/lib/cap-feature-map-monorepo.cjs +885 -0
- package/cap/bin/lib/cap-feature-map-shard.cjs +315 -0
- package/cap/bin/lib/cap-feature-map.cjs +1943 -0
- package/cap/bin/lib/cap-fitness-score.cjs +1075 -0
- package/cap/bin/lib/cap-impact-analysis.cjs +652 -0
- package/cap/bin/lib/cap-learn-review.cjs +1072 -0
- package/cap/bin/lib/cap-learning-signals.cjs +627 -0
- package/cap/bin/lib/cap-loader.cjs +227 -0
- package/cap/bin/lib/cap-logger.cjs +57 -0
- package/cap/bin/lib/cap-memory-bridge.cjs +764 -0
- package/cap/bin/lib/cap-memory-confidence.cjs +452 -0
- package/cap/bin/lib/cap-memory-dir.cjs +987 -0
- package/cap/bin/lib/cap-memory-engine.cjs +698 -0
- package/cap/bin/lib/cap-memory-extends.cjs +398 -0
- package/cap/bin/lib/cap-memory-graph.cjs +790 -0
- package/cap/bin/lib/cap-memory-migrate.cjs +2015 -0
- package/cap/bin/lib/cap-memory-pin.cjs +183 -0
- package/cap/bin/lib/cap-memory-platform.cjs +490 -0
- package/cap/bin/lib/cap-memory-prune.cjs +707 -0
- package/cap/bin/lib/cap-memory-schema.cjs +812 -0
- package/cap/bin/lib/cap-migrate-tags.cjs +309 -0
- package/cap/bin/lib/cap-migrate.cjs +540 -0
- package/cap/bin/lib/cap-pattern-apply.cjs +1203 -0
- package/cap/bin/lib/cap-pattern-pipeline.cjs +1034 -0
- package/cap/bin/lib/cap-plugin-manifest.cjs +80 -0
- package/cap/bin/lib/cap-realtime-affinity.cjs +399 -0
- package/cap/bin/lib/cap-reconcile.cjs +570 -0
- package/cap/bin/lib/cap-research-gate.cjs +218 -0
- package/cap/bin/lib/cap-scope-filter.cjs +402 -0
- package/cap/bin/lib/cap-semantic-pipeline.cjs +1038 -0
- package/cap/bin/lib/cap-session-extract.cjs +987 -0
- package/cap/bin/lib/cap-session.cjs +445 -0
- package/cap/bin/lib/cap-snapshot-linkage.cjs +963 -0
- package/cap/bin/lib/cap-stack-docs.cjs +646 -0
- package/cap/bin/lib/cap-tag-observer.cjs +371 -0
- package/cap/bin/lib/cap-tag-scanner.cjs +1766 -0
- package/cap/bin/lib/cap-telemetry.cjs +466 -0
- package/cap/bin/lib/cap-test-audit.cjs +1438 -0
- package/cap/bin/lib/cap-thread-migrator.cjs +307 -0
- package/cap/bin/lib/cap-thread-synthesis.cjs +545 -0
- package/cap/bin/lib/cap-thread-tracker.cjs +519 -0
- package/cap/bin/lib/cap-trace.cjs +399 -0
- package/cap/bin/lib/cap-trust-mode.cjs +336 -0
- package/cap/bin/lib/cap-ui-design-editor.cjs +642 -0
- package/cap/bin/lib/cap-ui-mind-map.cjs +712 -0
- package/cap/bin/lib/cap-ui-thread-nav.cjs +693 -0
- package/cap/bin/lib/cap-ui.cjs +1245 -0
- package/cap/bin/lib/cap-upgrade.cjs +1028 -0
- package/cap/bin/lib/cli/arg-helpers.cjs +49 -0
- package/cap/bin/lib/cli/frontmatter-router.cjs +31 -0
- package/cap/bin/lib/cli/init-router.cjs +68 -0
- package/cap/bin/lib/cli/phase-router.cjs +102 -0
- package/cap/bin/lib/cli/state-router.cjs +61 -0
- package/cap/bin/lib/cli/template-router.cjs +37 -0
- package/cap/bin/lib/cli/uat-router.cjs +29 -0
- package/cap/bin/lib/cli/validation-router.cjs +26 -0
- package/cap/bin/lib/cli/verification-router.cjs +31 -0
- package/cap/bin/lib/cli/workstream-router.cjs +39 -0
- package/cap/bin/lib/commands.cjs +961 -0
- package/cap/bin/lib/config.cjs +467 -0
- package/cap/bin/lib/convention-reader.cjs +258 -0
- package/cap/bin/lib/core.cjs +1241 -0
- package/cap/bin/lib/feature-aggregator.cjs +423 -0
- package/cap/bin/lib/frontmatter.cjs +337 -0
- package/cap/bin/lib/init.cjs +1443 -0
- package/cap/bin/lib/manifest-generator.cjs +383 -0
- package/cap/bin/lib/milestone.cjs +253 -0
- package/cap/bin/lib/model-profiles.cjs +69 -0
- package/cap/bin/lib/monorepo-context.cjs +226 -0
- package/cap/bin/lib/monorepo-migrator.cjs +509 -0
- package/cap/bin/lib/phase.cjs +889 -0
- package/cap/bin/lib/profile-output.cjs +989 -0
- package/cap/bin/lib/profile-pipeline.cjs +540 -0
- package/cap/bin/lib/roadmap.cjs +330 -0
- package/cap/bin/lib/security.cjs +394 -0
- package/cap/bin/lib/session-manager.cjs +292 -0
- package/cap/bin/lib/skeleton-generator.cjs +179 -0
- package/cap/bin/lib/state.cjs +1032 -0
- package/cap/bin/lib/template.cjs +231 -0
- package/cap/bin/lib/test-detector.cjs +62 -0
- package/cap/bin/lib/uat.cjs +283 -0
- package/cap/bin/lib/verify.cjs +889 -0
- package/cap/bin/lib/workspace-detector.cjs +371 -0
- package/cap/bin/lib/workstream.cjs +492 -0
- package/cap/commands/gsd/workstreams.md +63 -0
- package/cap/references/arc-standard.md +315 -0
- package/cap/references/cap-agent-architecture.md +101 -0
- package/cap/references/cap-gitignore-template +9 -0
- package/cap/references/cap-zero-deps.md +158 -0
- package/cap/references/checkpoints.md +778 -0
- package/cap/references/continuation-format.md +249 -0
- package/cap/references/contract-test-templates.md +312 -0
- package/cap/references/feature-map-template.md +25 -0
- package/cap/references/git-integration.md +295 -0
- package/cap/references/git-planning-commit.md +38 -0
- package/cap/references/model-profiles.md +174 -0
- package/cap/references/phase-numbering.md +126 -0
- package/cap/references/planning-config.md +202 -0
- package/cap/references/property-test-templates.md +316 -0
- package/cap/references/security-test-templates.md +347 -0
- package/cap/references/session-template.json +8 -0
- package/cap/references/tdd.md +263 -0
- package/cap/references/user-profiling.md +681 -0
- package/cap/references/verification-patterns.md +612 -0
- package/cap/templates/UAT.md +265 -0
- package/cap/templates/claude-md.md +175 -0
- package/cap/templates/codebase/architecture.md +255 -0
- package/cap/templates/codebase/concerns.md +310 -0
- package/cap/templates/codebase/conventions.md +307 -0
- package/cap/templates/codebase/integrations.md +280 -0
- package/cap/templates/codebase/stack.md +186 -0
- package/cap/templates/codebase/structure.md +285 -0
- package/cap/templates/codebase/testing.md +480 -0
- package/cap/templates/config.json +44 -0
- package/cap/templates/context.md +352 -0
- package/cap/templates/continue-here.md +78 -0
- package/cap/templates/copilot-instructions.md +7 -0
- package/cap/templates/debug-subagent-prompt.md +91 -0
- package/cap/templates/discussion-log.md +63 -0
- package/cap/templates/milestone-archive.md +123 -0
- package/cap/templates/milestone.md +115 -0
- package/cap/templates/phase-prompt.md +610 -0
- package/cap/templates/planner-subagent-prompt.md +117 -0
- package/cap/templates/project.md +186 -0
- package/cap/templates/requirements.md +231 -0
- package/cap/templates/research-project/ARCHITECTURE.md +204 -0
- package/cap/templates/research-project/FEATURES.md +147 -0
- package/cap/templates/research-project/PITFALLS.md +200 -0
- package/cap/templates/research-project/STACK.md +120 -0
- package/cap/templates/research-project/SUMMARY.md +170 -0
- package/cap/templates/research.md +552 -0
- package/cap/templates/roadmap.md +202 -0
- package/cap/templates/state.md +176 -0
- package/cap/templates/summary.md +364 -0
- package/cap/templates/user-preferences.md +498 -0
- package/cap/templates/verification-report.md +322 -0
- package/cap/workflows/add-phase.md +112 -0
- package/cap/workflows/add-tests.md +351 -0
- package/cap/workflows/add-todo.md +158 -0
- package/cap/workflows/audit-milestone.md +340 -0
- package/cap/workflows/audit-uat.md +109 -0
- package/cap/workflows/autonomous.md +891 -0
- package/cap/workflows/check-todos.md +177 -0
- package/cap/workflows/cleanup.md +152 -0
- package/cap/workflows/complete-milestone.md +767 -0
- package/cap/workflows/diagnose-issues.md +231 -0
- package/cap/workflows/discovery-phase.md +289 -0
- package/cap/workflows/discuss-phase-assumptions.md +653 -0
- package/cap/workflows/discuss-phase.md +1049 -0
- package/cap/workflows/do.md +104 -0
- package/cap/workflows/execute-phase.md +846 -0
- package/cap/workflows/execute-plan.md +514 -0
- package/cap/workflows/fast.md +105 -0
- package/cap/workflows/forensics.md +265 -0
- package/cap/workflows/health.md +181 -0
- package/cap/workflows/help.md +660 -0
- package/cap/workflows/insert-phase.md +130 -0
- package/cap/workflows/list-phase-assumptions.md +178 -0
- package/cap/workflows/list-workspaces.md +56 -0
- package/cap/workflows/manager.md +362 -0
- package/cap/workflows/map-codebase.md +377 -0
- package/cap/workflows/milestone-summary.md +223 -0
- package/cap/workflows/new-milestone.md +486 -0
- package/cap/workflows/new-project.md +1250 -0
- package/cap/workflows/new-workspace.md +237 -0
- package/cap/workflows/next.md +97 -0
- package/cap/workflows/node-repair.md +92 -0
- package/cap/workflows/note.md +156 -0
- package/cap/workflows/pause-work.md +176 -0
- package/cap/workflows/plan-milestone-gaps.md +273 -0
- package/cap/workflows/plan-phase.md +857 -0
- package/cap/workflows/plant-seed.md +169 -0
- package/cap/workflows/pr-branch.md +129 -0
- package/cap/workflows/profile-user.md +449 -0
- package/cap/workflows/progress.md +507 -0
- package/cap/workflows/quick.md +757 -0
- package/cap/workflows/remove-phase.md +155 -0
- package/cap/workflows/remove-workspace.md +90 -0
- package/cap/workflows/research-phase.md +82 -0
- package/cap/workflows/resume-project.md +326 -0
- package/cap/workflows/review.md +228 -0
- package/cap/workflows/session-report.md +146 -0
- package/cap/workflows/settings.md +283 -0
- package/cap/workflows/ship.md +228 -0
- package/cap/workflows/stats.md +60 -0
- package/cap/workflows/transition.md +671 -0
- package/cap/workflows/ui-phase.md +298 -0
- package/cap/workflows/ui-review.md +161 -0
- package/cap/workflows/update.md +323 -0
- package/cap/workflows/validate-phase.md +170 -0
- package/cap/workflows/verify-phase.md +254 -0
- package/cap/workflows/verify-work.md +637 -0
- package/commands/cap/annotate.md +165 -0
- package/commands/cap/brainstorm.md +393 -0
- package/commands/cap/checkpoint.md +106 -0
- package/commands/cap/completeness.md +94 -0
- package/commands/cap/continue.md +72 -0
- package/commands/cap/debug.md +588 -0
- package/commands/cap/deps.md +169 -0
- package/commands/cap/design.md +479 -0
- package/commands/cap/init.md +354 -0
- package/commands/cap/iterate.md +249 -0
- package/commands/cap/learn.md +459 -0
- package/commands/cap/memory.md +275 -0
- package/commands/cap/migrate-feature-map.md +91 -0
- package/commands/cap/migrate-memory.md +108 -0
- package/commands/cap/migrate-tags.md +91 -0
- package/commands/cap/migrate.md +131 -0
- package/commands/cap/prototype.md +510 -0
- package/commands/cap/reconcile.md +121 -0
- package/commands/cap/review.md +360 -0
- package/commands/cap/save.md +72 -0
- package/commands/cap/scan.md +404 -0
- package/commands/cap/start.md +356 -0
- package/commands/cap/status.md +118 -0
- package/commands/cap/test-audit.md +262 -0
- package/commands/cap/test.md +394 -0
- package/commands/cap/trace.md +133 -0
- package/commands/cap/ui.md +167 -0
- package/hooks/dist/cap-check-update.js +115 -0
- package/hooks/dist/cap-context-monitor.js +185 -0
- package/hooks/dist/cap-learn-review-hook.js +114 -0
- package/hooks/dist/cap-learning-hook.js +192 -0
- package/hooks/dist/cap-memory.js +299 -0
- package/hooks/dist/cap-prompt-guard.js +97 -0
- package/hooks/dist/cap-statusline.js +157 -0
- package/hooks/dist/cap-tag-observer.js +115 -0
- package/hooks/dist/cap-version-check.js +112 -0
- package/hooks/dist/cap-workflow-guard.js +175 -0
- package/hooks/hooks.json +55 -0
- package/package.json +58 -0
- package/scripts/base64-scan.sh +262 -0
- package/scripts/build-hooks.js +93 -0
- package/scripts/cap-removal-checklist.md +202 -0
- package/scripts/prompt-injection-scan.sh +199 -0
- package/scripts/run-tests.cjs +181 -0
- package/scripts/secret-scan.sh +227 -0
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
// @cap-feature(feature:F-036) Multi-Signal Affinity Engine — computes affinity scores between thread nodes using 8 weighted signals
|
|
2
|
+
// @cap-decision Pure logic module — all functions take data as input and return structured results. The ONLY exception is loadConfig which reads .cap/config.json.
|
|
3
|
+
// @cap-decision Signals split into realtime (structural lookups, fast) and post-session (deeper analysis) groups for phased execution.
|
|
4
|
+
// @cap-decision Jaccard similarity used as the foundational metric across multiple signals — simple, interpretable, well-bounded (0-1), and requires no external dependencies.
|
|
5
|
+
// @cap-decision Band classification uses configurable thresholds so teams can tune sensitivity without code changes.
|
|
6
|
+
// @cap-constraint Zero external dependencies — uses only Node.js built-ins (fs, path).
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
|
|
13
|
+
// --- Constants ---
|
|
14
|
+
|
|
15
|
+
// @cap-todo(ac:F-036/AC-2) Support 8 named signals: feature-id-overlap, shared-files, temporal-proximity, causal-chains (realtime); concept-overlap, problem-space-similarity, shared-decisions-deep, transitive-connections (post-session)
|
|
16
|
+
|
|
17
|
+
/** Ordered list of all signal names. */
|
|
18
|
+
const SIGNAL_NAMES = [
|
|
19
|
+
'feature-id-overlap',
|
|
20
|
+
'shared-files',
|
|
21
|
+
'temporal-proximity',
|
|
22
|
+
'causal-chains',
|
|
23
|
+
'concept-overlap',
|
|
24
|
+
'problem-space-similarity',
|
|
25
|
+
'shared-decisions-deep',
|
|
26
|
+
'transitive-connections',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** Signals computed during realtime (fast, structural lookups). */
|
|
30
|
+
const REALTIME_SIGNALS = [
|
|
31
|
+
'feature-id-overlap',
|
|
32
|
+
'shared-files',
|
|
33
|
+
'temporal-proximity',
|
|
34
|
+
'causal-chains',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** Signals computed during post-session analysis (deeper). */
|
|
38
|
+
const POST_SESSION_SIGNALS = [
|
|
39
|
+
'concept-overlap',
|
|
40
|
+
'problem-space-similarity',
|
|
41
|
+
'shared-decisions-deep',
|
|
42
|
+
'transitive-connections',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// @cap-todo(ac:F-036/AC-4) Signal weights configurable via .cap/config.json under key affinityWeights, defaults sum to 1.0
|
|
46
|
+
|
|
47
|
+
/** Default signal weights (sum to 1.0). */
|
|
48
|
+
const DEFAULT_WEIGHTS = {
|
|
49
|
+
'feature-id-overlap': 0.20,
|
|
50
|
+
'shared-files': 0.15,
|
|
51
|
+
'temporal-proximity': 0.05,
|
|
52
|
+
'causal-chains': 0.10,
|
|
53
|
+
'concept-overlap': 0.20,
|
|
54
|
+
'problem-space-similarity': 0.10,
|
|
55
|
+
'shared-decisions-deep': 0.10,
|
|
56
|
+
'transitive-connections': 0.10,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// @cap-todo(ac:F-036/AC-5) Classify scores into 4 bands: urgent (>=0.90), notify (0.75-0.89), silent (0.40-0.74), discard (<0.40) — thresholds configurable
|
|
60
|
+
|
|
61
|
+
/** Default band thresholds. */
|
|
62
|
+
const DEFAULT_BANDS = {
|
|
63
|
+
urgent: 0.90,
|
|
64
|
+
notify: 0.75,
|
|
65
|
+
silent: 0.40,
|
|
66
|
+
// Below silent threshold = discard
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Config file path relative to project root. */
|
|
70
|
+
const CONFIG_FILE = path.join('.cap', 'config.json');
|
|
71
|
+
|
|
72
|
+
// --- Types ---
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @typedef {'feature-id-overlap'|'shared-files'|'temporal-proximity'|'causal-chains'|'concept-overlap'|'problem-space-similarity'|'shared-decisions-deep'|'transitive-connections'} SignalName
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {Object} SignalResult
|
|
80
|
+
* @property {SignalName} signal - Signal name
|
|
81
|
+
* @property {number} score - Signal score (0.0-1.0)
|
|
82
|
+
* @property {string} reason - Human-readable explanation
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @typedef {'urgent'|'notify'|'silent'|'discard'} AffinityBand
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @typedef {Object} AffinityResult
|
|
91
|
+
* @property {string} sourceThreadId - First thread ID
|
|
92
|
+
* @property {string} targetThreadId - Second thread ID
|
|
93
|
+
* @property {number} compositeScore - Weighted composite score (0.0-1.0)
|
|
94
|
+
* @property {AffinityBand} band - Classification band
|
|
95
|
+
* @property {SignalResult[]} signals - Individual signal results
|
|
96
|
+
* @property {string} computedAt - ISO timestamp
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @typedef {Object} AffinityConfig
|
|
101
|
+
* @property {Object<SignalName, number>} weights - Signal weights (should sum to 1.0)
|
|
102
|
+
* @property {Object} bands - Band thresholds { urgent, notify, silent }
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @typedef {Object} AffinityContext
|
|
107
|
+
* @property {Object} graph - MemoryGraph instance
|
|
108
|
+
* @property {Object[]} allThreads - Array of Thread objects
|
|
109
|
+
* @property {Object[]} threadIndex - Array of ThreadIndexEntry objects
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @typedef {Object} Thread
|
|
114
|
+
* @property {string} id - Thread ID
|
|
115
|
+
* @property {string} name - Human-readable name
|
|
116
|
+
* @property {string} timestamp - ISO timestamp
|
|
117
|
+
* @property {string|null} parentThreadId - Parent thread ID
|
|
118
|
+
* @property {string|null} divergencePoint - Divergence description
|
|
119
|
+
* @property {string} problemStatement - Problem being explored
|
|
120
|
+
* @property {string} solutionShape - Solution direction
|
|
121
|
+
* @property {string[]} boundaryDecisions - Key decisions
|
|
122
|
+
* @property {string[]} featureIds - Associated feature IDs
|
|
123
|
+
* @property {string[]} keywords - Problem-space keywords
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
// --- Stop Words (shared with cap-thread-tracker.cjs pattern) ---
|
|
127
|
+
|
|
128
|
+
/** @type {Set<string>} */
|
|
129
|
+
const STOP_WORDS = new Set([
|
|
130
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
131
|
+
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
|
132
|
+
'should', 'may', 'might', 'shall', 'can', 'need', 'must', 'ought',
|
|
133
|
+
'and', 'but', 'or', 'nor', 'not', 'so', 'yet', 'both', 'either',
|
|
134
|
+
'neither', 'each', 'every', 'all', 'any', 'few', 'more', 'most',
|
|
135
|
+
'other', 'some', 'such', 'no', 'only', 'own', 'same', 'than',
|
|
136
|
+
'too', 'very', 'just', 'because', 'as', 'until', 'while', 'of',
|
|
137
|
+
'at', 'by', 'for', 'with', 'about', 'against', 'between', 'through',
|
|
138
|
+
'during', 'before', 'after', 'above', 'below', 'to', 'from', 'up',
|
|
139
|
+
'down', 'in', 'out', 'on', 'off', 'over', 'under', 'again',
|
|
140
|
+
'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why',
|
|
141
|
+
'how', 'what', 'which', 'who', 'whom', 'this', 'that', 'these',
|
|
142
|
+
'those', 'i', 'me', 'my', 'myself', 'we', 'our', 'ours', 'you',
|
|
143
|
+
'your', 'yours', 'he', 'him', 'his', 'she', 'her', 'hers', 'it',
|
|
144
|
+
'its', 'they', 'them', 'their', 'theirs', 'also', 'into', 'if',
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
// --- Utility Functions ---
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Compute Jaccard similarity between two sets.
|
|
151
|
+
* @param {Set<string>} setA
|
|
152
|
+
* @param {Set<string>} setB
|
|
153
|
+
* @returns {{ score: number, intersection: string[], union: string[] }}
|
|
154
|
+
*/
|
|
155
|
+
function jaccard(setA, setB) {
|
|
156
|
+
if (setA.size === 0 && setB.size === 0) {
|
|
157
|
+
return { score: 0, intersection: [], union: [] };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const intersection = [];
|
|
161
|
+
for (const item of setA) {
|
|
162
|
+
if (setB.has(item)) {
|
|
163
|
+
intersection.push(item);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const unionSet = new Set([...setA, ...setB]);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
score: intersection.length / unionSet.size,
|
|
171
|
+
intersection: intersection.sort(),
|
|
172
|
+
union: [...unionSet].sort(),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Extract keywords from text, filtering stop words and short words.
|
|
178
|
+
* @param {string} text
|
|
179
|
+
* @returns {string[]} Deduplicated sorted keywords
|
|
180
|
+
*/
|
|
181
|
+
function extractKeywords(text) {
|
|
182
|
+
if (!text || typeof text !== 'string') return [];
|
|
183
|
+
|
|
184
|
+
return [...new Set(
|
|
185
|
+
text
|
|
186
|
+
.toLowerCase()
|
|
187
|
+
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
188
|
+
.split(/\s+/)
|
|
189
|
+
.filter(w => w.length >= 3 && !STOP_WORDS.has(w))
|
|
190
|
+
)].sort();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Clamp a number to [0.0, 1.0].
|
|
195
|
+
* @param {number} n
|
|
196
|
+
* @returns {number}
|
|
197
|
+
*/
|
|
198
|
+
function clamp01(n) {
|
|
199
|
+
return Math.max(0, Math.min(1, n));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Truncate an array for display, appending "..." if truncated.
|
|
204
|
+
* @param {string[]} items
|
|
205
|
+
* @param {number} maxItems
|
|
206
|
+
* @returns {string} Comma-separated display string
|
|
207
|
+
*/
|
|
208
|
+
function truncateList(items, maxItems = 5) {
|
|
209
|
+
if (items.length <= maxItems) return items.join(', ');
|
|
210
|
+
return items.slice(0, maxItems).join(', ') + ', ...';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Find the graph node ID for a thread by its thread ID.
|
|
215
|
+
* Thread nodes have metadata.threadId matching the thr-XXXX id.
|
|
216
|
+
* @param {Object} graph - MemoryGraph
|
|
217
|
+
* @param {string} threadId - Thread ID (thr-XXXX)
|
|
218
|
+
* @returns {string|null} Graph node ID or null
|
|
219
|
+
*/
|
|
220
|
+
function findThreadNodeId(graph, threadId) {
|
|
221
|
+
for (const [nodeId, node] of Object.entries(graph.nodes || {})) {
|
|
222
|
+
if (node.type === 'thread' && node.metadata && node.metadata.threadId === threadId) {
|
|
223
|
+
return nodeId;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get feature IDs connected to a thread node in the graph.
|
|
231
|
+
* Looks for edges from the thread node to feature nodes.
|
|
232
|
+
* @param {Object} graph - MemoryGraph
|
|
233
|
+
* @param {string} threadNodeId - Graph node ID of the thread
|
|
234
|
+
* @returns {string[]} Array of feature node IDs
|
|
235
|
+
*/
|
|
236
|
+
function getConnectedFeatureNodeIds(graph, threadNodeId) {
|
|
237
|
+
const featureNodeIds = [];
|
|
238
|
+
for (const edge of (graph.edges || [])) {
|
|
239
|
+
if (!edge.active) continue;
|
|
240
|
+
let neighborId = null;
|
|
241
|
+
if (edge.source === threadNodeId) neighborId = edge.target;
|
|
242
|
+
else if (edge.target === threadNodeId) neighborId = edge.source;
|
|
243
|
+
if (neighborId && graph.nodes[neighborId] && graph.nodes[neighborId].type === 'feature') {
|
|
244
|
+
featureNodeIds.push(neighborId);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return featureNodeIds;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Collect file paths from feature nodes connected to a thread.
|
|
252
|
+
* @param {Object} graph - MemoryGraph
|
|
253
|
+
* @param {string} threadNodeId - Graph node ID of the thread
|
|
254
|
+
* @returns {Set<string>} Set of file paths
|
|
255
|
+
*/
|
|
256
|
+
function collectFilesForThread(graph, threadNodeId) {
|
|
257
|
+
const files = new Set();
|
|
258
|
+
const featureNodeIds = getConnectedFeatureNodeIds(graph, threadNodeId);
|
|
259
|
+
for (const fNodeId of featureNodeIds) {
|
|
260
|
+
const node = graph.nodes[fNodeId];
|
|
261
|
+
if (node && node.metadata && Array.isArray(node.metadata.files)) {
|
|
262
|
+
for (const f of node.metadata.files) {
|
|
263
|
+
files.add(f);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return files;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Get graph neighbor node IDs for a given node (active edges only).
|
|
272
|
+
* @param {Object} graph - MemoryGraph
|
|
273
|
+
* @param {string} nodeId - Node ID
|
|
274
|
+
* @returns {Set<string>} Set of neighbor node IDs
|
|
275
|
+
*/
|
|
276
|
+
function getGraphNeighbors(graph, nodeId) {
|
|
277
|
+
const neighbors = new Set();
|
|
278
|
+
for (const edge of (graph.edges || [])) {
|
|
279
|
+
if (!edge.active) continue;
|
|
280
|
+
if (edge.source === nodeId) neighbors.add(edge.target);
|
|
281
|
+
else if (edge.target === nodeId) neighbors.add(edge.source);
|
|
282
|
+
}
|
|
283
|
+
return neighbors;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// --- Signal Functions ---
|
|
287
|
+
// @cap-todo(ac:F-036/AC-3) Each signal returns independent score (0.0-1.0) and human-readable reason string
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Signal 1: Feature ID overlap between two threads.
|
|
291
|
+
* Uses Jaccard similarity on featureIds arrays.
|
|
292
|
+
* @param {Thread} threadA
|
|
293
|
+
* @param {Thread} threadB
|
|
294
|
+
* @param {AffinityContext} _context - Unused for this signal
|
|
295
|
+
* @returns {SignalResult}
|
|
296
|
+
*/
|
|
297
|
+
function signalFeatureIdOverlap(threadA, threadB, _context) {
|
|
298
|
+
const setA = new Set(threadA.featureIds || []);
|
|
299
|
+
const setB = new Set(threadB.featureIds || []);
|
|
300
|
+
const { score, intersection, union } = jaccard(setA, setB);
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
signal: 'feature-id-overlap',
|
|
304
|
+
score: clamp01(score),
|
|
305
|
+
reason: intersection.length > 0
|
|
306
|
+
? `Shares ${intersection.length} of ${union.length} features: ${truncateList(intersection)}`
|
|
307
|
+
: 'No shared feature IDs',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Signal 2: Shared files between threads.
|
|
313
|
+
* Collects file paths from feature nodes connected to each thread in the graph,
|
|
314
|
+
* then computes Jaccard similarity.
|
|
315
|
+
* @param {Thread} threadA
|
|
316
|
+
* @param {Thread} threadB
|
|
317
|
+
* @param {AffinityContext} context
|
|
318
|
+
* @returns {SignalResult}
|
|
319
|
+
*/
|
|
320
|
+
function signalSharedFiles(threadA, threadB, context) {
|
|
321
|
+
const graph = context.graph || { nodes: {}, edges: [] };
|
|
322
|
+
|
|
323
|
+
const nodeIdA = findThreadNodeId(graph, threadA.id);
|
|
324
|
+
const nodeIdB = findThreadNodeId(graph, threadB.id);
|
|
325
|
+
|
|
326
|
+
if (!nodeIdA || !nodeIdB) {
|
|
327
|
+
return {
|
|
328
|
+
signal: 'shared-files',
|
|
329
|
+
score: 0,
|
|
330
|
+
reason: 'Thread(s) not found in graph',
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const filesA = collectFilesForThread(graph, nodeIdA);
|
|
335
|
+
const filesB = collectFilesForThread(graph, nodeIdB);
|
|
336
|
+
const { score, intersection } = jaccard(filesA, filesB);
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
signal: 'shared-files',
|
|
340
|
+
score: clamp01(score),
|
|
341
|
+
reason: intersection.length > 0
|
|
342
|
+
? `${intersection.length} shared files: ${truncateList(intersection)}`
|
|
343
|
+
: 'No shared files',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Signal 3: Temporal proximity between threads.
|
|
349
|
+
* Inverse decay: 1 / (1 + daysBetween / 7).
|
|
350
|
+
* Same day ~1.0, 1 week apart ~0.5, 1 month ~0.19.
|
|
351
|
+
* @param {Thread} threadA
|
|
352
|
+
* @param {Thread} threadB
|
|
353
|
+
* @param {AffinityContext} _context
|
|
354
|
+
* @returns {SignalResult}
|
|
355
|
+
*/
|
|
356
|
+
function signalTemporalProximity(threadA, threadB, _context) {
|
|
357
|
+
const tsA = new Date(threadA.timestamp || 0).getTime();
|
|
358
|
+
const tsB = new Date(threadB.timestamp || 0).getTime();
|
|
359
|
+
|
|
360
|
+
if (isNaN(tsA) || isNaN(tsB)) {
|
|
361
|
+
return {
|
|
362
|
+
signal: 'temporal-proximity',
|
|
363
|
+
score: 0,
|
|
364
|
+
reason: 'Invalid timestamp(s)',
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const daysBetween = Math.abs(tsA - tsB) / (1000 * 60 * 60 * 24);
|
|
369
|
+
const score = 1 / (1 + daysBetween / 7);
|
|
370
|
+
|
|
371
|
+
const daysLabel = daysBetween < 1
|
|
372
|
+
? 'same day'
|
|
373
|
+
: `${Math.round(daysBetween)} day${Math.round(daysBetween) === 1 ? '' : 's'} apart`;
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
signal: 'temporal-proximity',
|
|
377
|
+
score: clamp01(score),
|
|
378
|
+
reason: daysLabel,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Signal 4: Causal chains between threads.
|
|
384
|
+
* Checks if Thread B's problem keywords appear in Thread A's solution/decisions,
|
|
385
|
+
* and vice versa. Uses bidirectional keyword overlap.
|
|
386
|
+
* @param {Thread} threadA
|
|
387
|
+
* @param {Thread} threadB
|
|
388
|
+
* @param {AffinityContext} _context
|
|
389
|
+
* @returns {SignalResult}
|
|
390
|
+
*/
|
|
391
|
+
function signalCausalChains(threadA, threadB, _context) {
|
|
392
|
+
// Extract keywords from A's solution space
|
|
393
|
+
const solutionKeywordsA = new Set([
|
|
394
|
+
...extractKeywords(threadA.solutionShape || ''),
|
|
395
|
+
...((threadA.boundaryDecisions || []).flatMap(d => extractKeywords(d))),
|
|
396
|
+
]);
|
|
397
|
+
|
|
398
|
+
// Extract keywords from B's problem space
|
|
399
|
+
const problemKeywordsB = new Set(extractKeywords(threadB.problemStatement || ''));
|
|
400
|
+
|
|
401
|
+
// Forward: A's solution -> B's problem
|
|
402
|
+
const forwardOverlap = [];
|
|
403
|
+
for (const kw of problemKeywordsB) {
|
|
404
|
+
if (solutionKeywordsA.has(kw)) forwardOverlap.push(kw);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Extract keywords from B's solution space
|
|
408
|
+
const solutionKeywordsB = new Set([
|
|
409
|
+
...extractKeywords(threadB.solutionShape || ''),
|
|
410
|
+
...((threadB.boundaryDecisions || []).flatMap(d => extractKeywords(d))),
|
|
411
|
+
]);
|
|
412
|
+
|
|
413
|
+
// Extract keywords from A's problem space
|
|
414
|
+
const problemKeywordsA = new Set(extractKeywords(threadA.problemStatement || ''));
|
|
415
|
+
|
|
416
|
+
// Reverse: B's solution -> A's problem
|
|
417
|
+
const reverseOverlap = [];
|
|
418
|
+
for (const kw of problemKeywordsA) {
|
|
419
|
+
if (solutionKeywordsB.has(kw)) reverseOverlap.push(kw);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Combine unique overlapping keywords
|
|
423
|
+
const allOverlap = [...new Set([...forwardOverlap, ...reverseOverlap])].sort();
|
|
424
|
+
|
|
425
|
+
// Score: proportion of problem keywords matched, using the best direction
|
|
426
|
+
const forwardDenom = problemKeywordsB.size || 1;
|
|
427
|
+
const reverseDenom = problemKeywordsA.size || 1;
|
|
428
|
+
const forwardScore = forwardOverlap.length / forwardDenom;
|
|
429
|
+
const reverseScore = reverseOverlap.length / reverseDenom;
|
|
430
|
+
const score = Math.max(forwardScore, reverseScore);
|
|
431
|
+
|
|
432
|
+
let reason;
|
|
433
|
+
if (allOverlap.length > 0) {
|
|
434
|
+
const direction = forwardScore >= reverseScore ? 'A -> B' : 'B -> A';
|
|
435
|
+
reason = `Causal chain detected (${direction}): ${allOverlap.length} shared concepts: ${truncateList(allOverlap)}`;
|
|
436
|
+
} else {
|
|
437
|
+
reason = 'No causal chain detected';
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
signal: 'causal-chains',
|
|
442
|
+
score: clamp01(score),
|
|
443
|
+
reason,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Signal 5: Concept overlap between threads.
|
|
449
|
+
* For the prototype, uses Jaccard on keyword sets.
|
|
450
|
+
* @cap-risk F-037 will enhance this with TF-IDF/taxonomy — current implementation is a keyword proxy only.
|
|
451
|
+
* @param {Thread} threadA
|
|
452
|
+
* @param {Thread} threadB
|
|
453
|
+
* @param {AffinityContext} _context
|
|
454
|
+
* @returns {SignalResult}
|
|
455
|
+
*/
|
|
456
|
+
function signalConceptOverlap(threadA, threadB, _context) {
|
|
457
|
+
const setA = new Set(threadA.keywords || []);
|
|
458
|
+
const setB = new Set(threadB.keywords || []);
|
|
459
|
+
const { score, intersection } = jaccard(setA, setB);
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
signal: 'concept-overlap',
|
|
463
|
+
score: clamp01(score),
|
|
464
|
+
reason: intersection.length > 0
|
|
465
|
+
? `${intersection.length} shared concepts from keyword analysis: ${truncateList(intersection)}`
|
|
466
|
+
: 'No shared concepts',
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Signal 6: Problem-space similarity.
|
|
472
|
+
* Extracts keywords from problemStatement specifically and computes Jaccard.
|
|
473
|
+
* @param {Thread} threadA
|
|
474
|
+
* @param {Thread} threadB
|
|
475
|
+
* @param {AffinityContext} _context
|
|
476
|
+
* @returns {SignalResult}
|
|
477
|
+
*/
|
|
478
|
+
function signalProblemSpaceSimilarity(threadA, threadB, _context) {
|
|
479
|
+
const kwA = extractKeywords(threadA.problemStatement || '');
|
|
480
|
+
const kwB = extractKeywords(threadB.problemStatement || '');
|
|
481
|
+
const setA = new Set(kwA);
|
|
482
|
+
const setB = new Set(kwB);
|
|
483
|
+
const { score, intersection } = jaccard(setA, setB);
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
signal: 'problem-space-similarity',
|
|
487
|
+
score: clamp01(score),
|
|
488
|
+
reason: intersection.length > 0
|
|
489
|
+
? `Problem statements share ${intersection.length} keywords: ${truncateList(intersection)}`
|
|
490
|
+
: 'No shared problem-space keywords',
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Signal 7: Shared decisions (deep analysis).
|
|
496
|
+
* Extracts keywords from all boundaryDecisions of each thread and computes Jaccard.
|
|
497
|
+
* @param {Thread} threadA
|
|
498
|
+
* @param {Thread} threadB
|
|
499
|
+
* @param {AffinityContext} _context
|
|
500
|
+
* @returns {SignalResult}
|
|
501
|
+
*/
|
|
502
|
+
function signalSharedDecisionsDeep(threadA, threadB, _context) {
|
|
503
|
+
const decisionsA = threadA.boundaryDecisions || [];
|
|
504
|
+
const decisionsB = threadB.boundaryDecisions || [];
|
|
505
|
+
|
|
506
|
+
const kwA = new Set(decisionsA.flatMap(d => extractKeywords(d)));
|
|
507
|
+
const kwB = new Set(decisionsB.flatMap(d => extractKeywords(d)));
|
|
508
|
+
const { score, intersection } = jaccard(kwA, kwB);
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
signal: 'shared-decisions-deep',
|
|
512
|
+
score: clamp01(score),
|
|
513
|
+
reason: intersection.length > 0
|
|
514
|
+
? `${intersection.length} shared decision keywords across ${decisionsA.length + decisionsB.length} decisions: ${truncateList(intersection)}`
|
|
515
|
+
: 'No shared decision keywords',
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Signal 8: Transitive connections via shared graph neighbors.
|
|
521
|
+
* Counts thread nodes connected to both A and B in the graph.
|
|
522
|
+
* Score: |shared| / max(|neighbors_A|, |neighbors_B|, 1)
|
|
523
|
+
* @param {Thread} threadA
|
|
524
|
+
* @param {Thread} threadB
|
|
525
|
+
* @param {AffinityContext} context
|
|
526
|
+
* @returns {SignalResult}
|
|
527
|
+
*/
|
|
528
|
+
function signalTransitiveConnections(threadA, threadB, context) {
|
|
529
|
+
const graph = context.graph || { nodes: {}, edges: [] };
|
|
530
|
+
|
|
531
|
+
const nodeIdA = findThreadNodeId(graph, threadA.id);
|
|
532
|
+
const nodeIdB = findThreadNodeId(graph, threadB.id);
|
|
533
|
+
|
|
534
|
+
if (!nodeIdA || !nodeIdB) {
|
|
535
|
+
return {
|
|
536
|
+
signal: 'transitive-connections',
|
|
537
|
+
score: 0,
|
|
538
|
+
reason: 'Thread(s) not found in graph',
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const neighborsA = getGraphNeighbors(graph, nodeIdA);
|
|
543
|
+
const neighborsB = getGraphNeighbors(graph, nodeIdB);
|
|
544
|
+
|
|
545
|
+
// Remove direct connection between A and B from neighbor sets
|
|
546
|
+
neighborsA.delete(nodeIdB);
|
|
547
|
+
neighborsB.delete(nodeIdA);
|
|
548
|
+
|
|
549
|
+
const shared = [];
|
|
550
|
+
for (const n of neighborsA) {
|
|
551
|
+
if (neighborsB.has(n)) {
|
|
552
|
+
shared.push(n);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const denom = Math.max(neighborsA.size, neighborsB.size, 1);
|
|
557
|
+
const score = shared.length / denom;
|
|
558
|
+
|
|
559
|
+
// Resolve labels for shared neighbors
|
|
560
|
+
const labels = shared
|
|
561
|
+
.map(nid => (graph.nodes[nid] && graph.nodes[nid].label) || nid)
|
|
562
|
+
.sort();
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
signal: 'transitive-connections',
|
|
566
|
+
score: clamp01(score),
|
|
567
|
+
reason: shared.length > 0
|
|
568
|
+
? `${shared.length} shared graph neighbors: ${truncateList(labels)}`
|
|
569
|
+
: 'No shared graph neighbors',
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// --- Signal Registry ---
|
|
574
|
+
|
|
575
|
+
/** @type {Object<SignalName, function(Thread, Thread, AffinityContext): SignalResult>} */
|
|
576
|
+
const SIGNAL_FUNCTIONS = {
|
|
577
|
+
'feature-id-overlap': signalFeatureIdOverlap,
|
|
578
|
+
'shared-files': signalSharedFiles,
|
|
579
|
+
'temporal-proximity': signalTemporalProximity,
|
|
580
|
+
'causal-chains': signalCausalChains,
|
|
581
|
+
'concept-overlap': signalConceptOverlap,
|
|
582
|
+
'problem-space-similarity': signalProblemSpaceSimilarity,
|
|
583
|
+
'shared-decisions-deep': signalSharedDecisionsDeep,
|
|
584
|
+
'transitive-connections': signalTransitiveConnections,
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
// --- Configuration ---
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Load affinity configuration from .cap/config.json.
|
|
591
|
+
* This is the ONLY function in the module that performs I/O.
|
|
592
|
+
* @param {string} cwd - Project root directory
|
|
593
|
+
* @returns {AffinityConfig} Merged configuration (user overrides + defaults)
|
|
594
|
+
*/
|
|
595
|
+
function loadConfig(cwd) {
|
|
596
|
+
const configPath = path.join(cwd, CONFIG_FILE);
|
|
597
|
+
let userConfig = {};
|
|
598
|
+
|
|
599
|
+
try {
|
|
600
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
601
|
+
const parsed = JSON.parse(raw);
|
|
602
|
+
userConfig = parsed || {};
|
|
603
|
+
} catch (_err) {
|
|
604
|
+
// No config file or invalid JSON — use defaults
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return mergeWithDefaults(userConfig);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Merge user-supplied configuration with defaults.
|
|
612
|
+
* Validates that weights sum to 1.0 (within tolerance) and normalizes if needed.
|
|
613
|
+
* @param {Object} userConfig - Raw user config (may have affinityWeights, affinityBands)
|
|
614
|
+
* @returns {AffinityConfig}
|
|
615
|
+
*/
|
|
616
|
+
function mergeWithDefaults(userConfig) {
|
|
617
|
+
// Merge weights
|
|
618
|
+
let weights = { ...DEFAULT_WEIGHTS };
|
|
619
|
+
if (userConfig.affinityWeights && typeof userConfig.affinityWeights === 'object') {
|
|
620
|
+
for (const signal of SIGNAL_NAMES) {
|
|
621
|
+
if (typeof userConfig.affinityWeights[signal] === 'number') {
|
|
622
|
+
weights[signal] = userConfig.affinityWeights[signal];
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Normalize weights to sum to 1.0
|
|
628
|
+
const weightSum = Object.values(weights).reduce((a, b) => a + b, 0);
|
|
629
|
+
if (Math.abs(weightSum - 1.0) > 0.001) {
|
|
630
|
+
// @cap-risk Weights that do not sum to 1.0 are silently normalized — could mask user config errors
|
|
631
|
+
for (const signal of SIGNAL_NAMES) {
|
|
632
|
+
weights[signal] = weights[signal] / weightSum;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Merge band thresholds
|
|
637
|
+
let bands = { ...DEFAULT_BANDS };
|
|
638
|
+
if (userConfig.affinityBands && typeof userConfig.affinityBands === 'object') {
|
|
639
|
+
if (typeof userConfig.affinityBands.urgent === 'number') bands.urgent = userConfig.affinityBands.urgent;
|
|
640
|
+
if (typeof userConfig.affinityBands.notify === 'number') bands.notify = userConfig.affinityBands.notify;
|
|
641
|
+
if (typeof userConfig.affinityBands.silent === 'number') bands.silent = userConfig.affinityBands.silent;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return { weights, bands };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// --- Core Functions ---
|
|
648
|
+
|
|
649
|
+
// @cap-todo(ac:F-036/AC-1) Compute composite affinity score (0.0-1.0) between any two thread nodes by combining 8 weighted signal scores
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Compute the full affinity between two threads using all 8 signals.
|
|
653
|
+
* @param {Thread} threadA - First thread
|
|
654
|
+
* @param {Thread} threadB - Second thread
|
|
655
|
+
* @param {AffinityContext} context - Graph, threads, and index data
|
|
656
|
+
* @param {AffinityConfig} [config] - Optional config (uses defaults if omitted)
|
|
657
|
+
* @returns {AffinityResult}
|
|
658
|
+
*/
|
|
659
|
+
function computeAffinity(threadA, threadB, context, config) {
|
|
660
|
+
const cfg = config || { weights: { ...DEFAULT_WEIGHTS }, bands: { ...DEFAULT_BANDS } };
|
|
661
|
+
return _computeWithSignals(threadA, threadB, context, cfg, SIGNAL_NAMES);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Compute affinity using only the 4 realtime signals.
|
|
666
|
+
* Weights are renormalized to sum to 1.0 across the selected signals.
|
|
667
|
+
* @param {Thread} threadA
|
|
668
|
+
* @param {Thread} threadB
|
|
669
|
+
* @param {AffinityContext} context
|
|
670
|
+
* @param {AffinityConfig} [config]
|
|
671
|
+
* @returns {AffinityResult}
|
|
672
|
+
*/
|
|
673
|
+
function computeRealtimeAffinity(threadA, threadB, context, config) {
|
|
674
|
+
const cfg = config || { weights: { ...DEFAULT_WEIGHTS }, bands: { ...DEFAULT_BANDS } };
|
|
675
|
+
return _computeWithSignals(threadA, threadB, context, cfg, REALTIME_SIGNALS);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Compute affinity using only the 4 post-session signals.
|
|
680
|
+
* Weights are renormalized to sum to 1.0 across the selected signals.
|
|
681
|
+
* @param {Thread} threadA
|
|
682
|
+
* @param {Thread} threadB
|
|
683
|
+
* @param {AffinityContext} context
|
|
684
|
+
* @param {AffinityConfig} [config]
|
|
685
|
+
* @returns {AffinityResult}
|
|
686
|
+
*/
|
|
687
|
+
function computePostSessionAffinity(threadA, threadB, context, config) {
|
|
688
|
+
const cfg = config || { weights: { ...DEFAULT_WEIGHTS }, bands: { ...DEFAULT_BANDS } };
|
|
689
|
+
return _computeWithSignals(threadA, threadB, context, cfg, POST_SESSION_SIGNALS);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Internal: compute affinity using a specific subset of signals.
|
|
694
|
+
* @param {Thread} threadA
|
|
695
|
+
* @param {Thread} threadB
|
|
696
|
+
* @param {AffinityContext} context
|
|
697
|
+
* @param {AffinityConfig} config
|
|
698
|
+
* @param {SignalName[]} signalNames - Which signals to use
|
|
699
|
+
* @returns {AffinityResult}
|
|
700
|
+
*/
|
|
701
|
+
function _computeWithSignals(threadA, threadB, context, config, signalNames) {
|
|
702
|
+
const signals = [];
|
|
703
|
+
let compositeScore = 0;
|
|
704
|
+
|
|
705
|
+
// Compute renormalized weights for the selected signal subset
|
|
706
|
+
const subsetWeightSum = signalNames.reduce((sum, name) => sum + (config.weights[name] || 0), 0);
|
|
707
|
+
const normalizer = subsetWeightSum > 0 ? subsetWeightSum : 1;
|
|
708
|
+
|
|
709
|
+
for (const name of signalNames) {
|
|
710
|
+
const fn = SIGNAL_FUNCTIONS[name];
|
|
711
|
+
if (!fn) continue;
|
|
712
|
+
|
|
713
|
+
const result = fn(threadA, threadB, context);
|
|
714
|
+
signals.push(result);
|
|
715
|
+
|
|
716
|
+
const weight = (config.weights[name] || 0) / normalizer;
|
|
717
|
+
compositeScore += result.score * weight;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
compositeScore = clamp01(compositeScore);
|
|
721
|
+
const band = classifyBand(compositeScore, config.bands);
|
|
722
|
+
|
|
723
|
+
return {
|
|
724
|
+
sourceThreadId: threadA.id,
|
|
725
|
+
targetThreadId: threadB.id,
|
|
726
|
+
compositeScore,
|
|
727
|
+
band,
|
|
728
|
+
signals,
|
|
729
|
+
computedAt: new Date().toISOString(),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// @cap-todo(ac:F-036/AC-8) <200ms for single thread pair with 100 thread nodes
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Compute affinity for all unique thread pairs.
|
|
737
|
+
* Returns results sorted by composite score descending.
|
|
738
|
+
* @param {Thread[]} threads - Array of threads to compare
|
|
739
|
+
* @param {AffinityContext} context
|
|
740
|
+
* @param {AffinityConfig} [config]
|
|
741
|
+
* @returns {AffinityResult[]}
|
|
742
|
+
*/
|
|
743
|
+
function computeAffinityBatch(threads, context, config) {
|
|
744
|
+
const cfg = config || { weights: { ...DEFAULT_WEIGHTS }, bands: { ...DEFAULT_BANDS } };
|
|
745
|
+
const results = [];
|
|
746
|
+
|
|
747
|
+
for (let i = 0; i < threads.length; i++) {
|
|
748
|
+
for (let j = i + 1; j < threads.length; j++) {
|
|
749
|
+
results.push(computeAffinity(threads[i], threads[j], context, cfg));
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Sort descending by composite score
|
|
754
|
+
results.sort((a, b) => b.compositeScore - a.compositeScore);
|
|
755
|
+
|
|
756
|
+
return results;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// @cap-todo(ac:F-036/AC-5) classifyBand with configurable thresholds
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Classify a composite score into an affinity band.
|
|
763
|
+
* @param {number} score - Composite score (0.0-1.0)
|
|
764
|
+
* @param {Object} [bandConfig] - Band thresholds { urgent, notify, silent }
|
|
765
|
+
* @returns {AffinityBand}
|
|
766
|
+
*/
|
|
767
|
+
function classifyBand(score, bandConfig) {
|
|
768
|
+
const bands = bandConfig || DEFAULT_BANDS;
|
|
769
|
+
|
|
770
|
+
if (score >= bands.urgent) return 'urgent';
|
|
771
|
+
if (score >= bands.notify) return 'notify';
|
|
772
|
+
if (score >= bands.silent) return 'silent';
|
|
773
|
+
return 'discard';
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// @cap-todo(ac:F-036/AC-6) Discard band scores not persisted; others stored as weighted edges with type "affinity"
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Filter affinity results to only those that should be persisted (non-discard).
|
|
780
|
+
* @param {AffinityResult[]} results
|
|
781
|
+
* @returns {AffinityResult[]}
|
|
782
|
+
*/
|
|
783
|
+
function filterPersistable(results) {
|
|
784
|
+
return results.filter(r => r.band !== 'discard');
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Convert an affinity result into a graph edge suitable for addEdge().
|
|
789
|
+
* Only call this for persistable (non-discard) results.
|
|
790
|
+
* @param {AffinityResult} result
|
|
791
|
+
* @param {Object} graph - MemoryGraph to resolve thread node IDs
|
|
792
|
+
* @returns {Object|null} Graph edge object or null if thread nodes not found
|
|
793
|
+
*/
|
|
794
|
+
function toGraphEdge(result, graph) {
|
|
795
|
+
const sourceNodeId = findThreadNodeId(graph, result.sourceThreadId);
|
|
796
|
+
const targetNodeId = findThreadNodeId(graph, result.targetThreadId);
|
|
797
|
+
|
|
798
|
+
if (!sourceNodeId || !targetNodeId) return null;
|
|
799
|
+
|
|
800
|
+
return {
|
|
801
|
+
source: sourceNodeId,
|
|
802
|
+
target: targetNodeId,
|
|
803
|
+
type: 'affinity',
|
|
804
|
+
createdAt: result.computedAt,
|
|
805
|
+
active: true,
|
|
806
|
+
metadata: {
|
|
807
|
+
compositeScore: result.compositeScore,
|
|
808
|
+
band: result.band,
|
|
809
|
+
signals: result.signals.map(s => ({
|
|
810
|
+
signal: s.signal,
|
|
811
|
+
score: s.score,
|
|
812
|
+
reason: s.reason,
|
|
813
|
+
})),
|
|
814
|
+
},
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// --- Module Exports ---
|
|
819
|
+
|
|
820
|
+
// @cap-todo(ac:F-036/AC-7) Pure logic module — no direct I/O (except loadConfig)
|
|
821
|
+
// @cap-decision Exporting internal helpers prefixed with _ for testing, following project convention.
|
|
822
|
+
|
|
823
|
+
module.exports = {
|
|
824
|
+
// Core affinity computation
|
|
825
|
+
computeAffinity,
|
|
826
|
+
computeRealtimeAffinity,
|
|
827
|
+
computePostSessionAffinity,
|
|
828
|
+
computeAffinityBatch,
|
|
829
|
+
|
|
830
|
+
// Classification and filtering
|
|
831
|
+
classifyBand,
|
|
832
|
+
filterPersistable,
|
|
833
|
+
toGraphEdge,
|
|
834
|
+
|
|
835
|
+
// Configuration
|
|
836
|
+
loadConfig,
|
|
837
|
+
mergeWithDefaults,
|
|
838
|
+
|
|
839
|
+
// Constants
|
|
840
|
+
SIGNAL_NAMES,
|
|
841
|
+
REALTIME_SIGNALS,
|
|
842
|
+
POST_SESSION_SIGNALS,
|
|
843
|
+
DEFAULT_WEIGHTS,
|
|
844
|
+
DEFAULT_BANDS,
|
|
845
|
+
|
|
846
|
+
// Internal (for testing)
|
|
847
|
+
_signalFeatureIdOverlap: signalFeatureIdOverlap,
|
|
848
|
+
_signalSharedFiles: signalSharedFiles,
|
|
849
|
+
_signalTemporalProximity: signalTemporalProximity,
|
|
850
|
+
_signalCausalChains: signalCausalChains,
|
|
851
|
+
_signalConceptOverlap: signalConceptOverlap,
|
|
852
|
+
_signalProblemSpaceSimilarity: signalProblemSpaceSimilarity,
|
|
853
|
+
_signalSharedDecisionsDeep: signalSharedDecisionsDeep,
|
|
854
|
+
_signalTransitiveConnections: signalTransitiveConnections,
|
|
855
|
+
_jaccard: jaccard,
|
|
856
|
+
_extractKeywords: extractKeywords,
|
|
857
|
+
_clamp01: clamp01,
|
|
858
|
+
_findThreadNodeId: findThreadNodeId,
|
|
859
|
+
_collectFilesForThread: collectFilesForThread,
|
|
860
|
+
_getGraphNeighbors: getGraphNeighbors,
|
|
861
|
+
_computeWithSignals,
|
|
862
|
+
};
|